@softize/opus 12.9.0 → 12.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/bin/lib/check.mjs +1103 -310
- package/bin/lib/copy.mjs +74 -6
- package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
- package/docs/adr/0004-page-content-state-is-composed.md +65 -0
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +97 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
- package/docs/code-style.md +6 -2
- package/package.json +1 -1
- package/registry/instructions/opus.md +5 -0
- package/registry/skills/build-opus-ui/SKILL.md +27 -16
- package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
- package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
- package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
- package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
- package/registry/templates/app/src/App.tsx +1 -1
- package/src/core/dictionary.ts +52 -14
- package/src/core/index.ts +10 -0
- package/src/core/ui-context.ts +29 -0
- package/src/schema/drivers/zod.ts +34 -19
- package/src/ui/components/patterns/action-form-card.tsx +18 -12
- package/src/ui/components/patterns/confirm.tsx +26 -3
- package/src/ui/components/patterns/content-header.tsx +335 -61
- package/src/ui/components/patterns/data-state.tsx +23 -10
- package/src/ui/components/patterns/form.tsx +1 -1
- package/src/ui/components/patterns/list.tsx +1096 -777
- package/src/ui/components/patterns/page-state.tsx +115 -0
- package/src/ui/components/patterns/page.tsx +231 -41
- package/src/ui/components/patterns/sidebar.tsx +354 -80
- package/src/ui/components/patterns/trigger.tsx +13 -9
- package/src/ui/components/patterns/view.tsx +7 -11
- package/src/ui/components/primitives/alert-dialog.tsx +7 -5
- package/src/ui/components/primitives/alert.tsx +298 -80
- package/src/ui/components/primitives/ask.tsx +2 -1
- package/src/ui/components/primitives/badge.tsx +91 -30
- package/src/ui/components/primitives/button.tsx +99 -60
- package/src/ui/components/primitives/calendar.tsx +39 -39
- package/src/ui/components/primitives/card.tsx +96 -23
- package/src/ui/components/primitives/detail.tsx +2 -2
- package/src/ui/components/primitives/dictionary-value.tsx +9 -14
- package/src/ui/components/primitives/dot.tsx +74 -21
- package/src/ui/components/primitives/drawer.tsx +33 -20
- package/src/ui/components/primitives/field.tsx +4 -4
- package/src/ui/components/primitives/item.tsx +137 -81
- package/src/ui/components/primitives/menu.tsx +11 -3
- package/src/ui/components/primitives/metric-card.tsx +133 -0
- package/src/ui/components/primitives/table.tsx +2 -2
- package/src/ui/components/primitives/tooltip.tsx +1 -1
- package/src/ui/docs/DocBrowser.tsx +3 -3
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/alert-dialog.md +8 -8
- package/src/ui/docs/content/alert.md +54 -23
- package/src/ui/docs/content/badge.md +18 -19
- package/src/ui/docs/content/button.md +12 -9
- package/src/ui/docs/content/card.md +5 -5
- package/src/ui/docs/content/content.md +44 -0
- package/src/ui/docs/content/customization.md +2 -2
- package/src/ui/docs/content/detail.md +5 -2
- package/src/ui/docs/content/dialog.md +2 -2
- package/src/ui/docs/content/dictionary-value.md +11 -10
- package/src/ui/docs/content/dot.md +7 -7
- package/src/ui/docs/content/drawer.md +6 -3
- package/src/ui/docs/content/field.md +1 -1
- package/src/ui/docs/content/input-group.md +3 -2
- package/src/ui/docs/content/item.md +47 -21
- package/src/ui/docs/content/menu.md +5 -4
- package/src/ui/docs/content/metric-card.md +41 -0
- package/src/ui/docs/content/page-state.md +45 -0
- package/src/ui/docs/content/page.md +48 -10
- package/src/ui/docs/content/semantic-context.md +63 -0
- package/src/ui/docs/content/sidebar.md +4 -4
- package/src/ui/docs/content/skeleton.md +2 -2
- package/src/ui/docs/content/table.md +3 -3
- package/src/ui/docs/content/tokens.md +28 -0
- package/src/ui/docs/content/tooltip.md +15 -1
- package/src/ui/docs/doc-client.tsx +2 -2
- package/src/ui/docs/registry.tsx +596 -228
- package/src/ui/lib/semantic-context.ts +30 -0
- package/src/ui/meta.ts +292 -270
- package/src/ui/react.tsx +378 -111
- package/src/ui/theme.css +66 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `hidden` e headers ordenáveis (`sortable` → `sort: '<key>:<dir>'` no input; o
|
|
7
7
|
* handler implementa o orderBy). Células custom entram por cima via `cells`.
|
|
8
8
|
* - `filters` (FilterSpec) → toolbar: os não-avançados inline; os `advanced: true` no
|
|
9
|
-
* modal "Filtros"
|
|
9
|
+
* modal "Filtros". Aplicados viram CHIPS removíveis.
|
|
10
10
|
* - `text` → caixa de busca (convenção: param `q` no input).
|
|
11
11
|
* - `sort.default` → ordenação inicial.
|
|
12
12
|
*
|
|
@@ -19,8 +19,26 @@
|
|
|
19
19
|
* Empty/loading/error padronizados. `data-action` na raiz (selector E2E).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
|
|
22
|
+
import {
|
|
23
|
+
useEffect,
|
|
24
|
+
useLayoutEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
type ReactNode,
|
|
29
|
+
} from "react";
|
|
30
|
+
import {
|
|
31
|
+
ArrowDown,
|
|
32
|
+
ArrowUp,
|
|
33
|
+
ArrowUpDown,
|
|
34
|
+
Calendar as CalendarIcon,
|
|
35
|
+
Eraser,
|
|
36
|
+
Filter,
|
|
37
|
+
RefreshCw,
|
|
38
|
+
Settings2,
|
|
39
|
+
Table2,
|
|
40
|
+
X,
|
|
41
|
+
} from "lucide-react";
|
|
24
42
|
import type {
|
|
25
43
|
ActionDef,
|
|
26
44
|
FilterSpec,
|
|
@@ -29,14 +47,23 @@ import type {
|
|
|
29
47
|
ListAction,
|
|
30
48
|
ListColumnSpec,
|
|
31
49
|
SortSpec,
|
|
32
|
-
} from
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
import {
|
|
39
|
-
|
|
50
|
+
} from "../../../core/index.ts";
|
|
51
|
+
import {
|
|
52
|
+
dictionaryDescriptor,
|
|
53
|
+
getLogicalType,
|
|
54
|
+
type DictionaryDescriptor,
|
|
55
|
+
} from "../../../core/index.ts";
|
|
56
|
+
import {
|
|
57
|
+
useDicts,
|
|
58
|
+
useListAction,
|
|
59
|
+
useLookupAction,
|
|
60
|
+
type DictLike,
|
|
61
|
+
} from "../../drivers/react.tsx";
|
|
62
|
+
import { cn } from "../../lib/cn.ts";
|
|
63
|
+
import { objectSchemaShape } from "../../lib/object-schema.ts";
|
|
64
|
+
import { Badge } from "../primitives/badge.tsx";
|
|
65
|
+
import { DictionaryValue } from "../primitives/dictionary-value.tsx";
|
|
66
|
+
import { EmptyValue, isAbsentValue } from "../primitives/empty-value.tsx";
|
|
40
67
|
import {
|
|
41
68
|
Pagination,
|
|
42
69
|
PaginationContent,
|
|
@@ -45,11 +72,15 @@ import {
|
|
|
45
72
|
PaginationLink,
|
|
46
73
|
PaginationNext,
|
|
47
74
|
PaginationPrevious,
|
|
48
|
-
} from
|
|
49
|
-
import { Button } from
|
|
50
|
-
import { Calendar } from
|
|
51
|
-
import { Checkbox } from
|
|
52
|
-
import {
|
|
75
|
+
} from "../primitives/pagination.tsx";
|
|
76
|
+
import { Button } from "../primitives/button.tsx";
|
|
77
|
+
import { Calendar } from "../primitives/calendar.tsx";
|
|
78
|
+
import { Checkbox } from "../primitives/checkbox.tsx";
|
|
79
|
+
import {
|
|
80
|
+
Popover,
|
|
81
|
+
PopoverContent,
|
|
82
|
+
PopoverTrigger,
|
|
83
|
+
} from "../primitives/popover.tsx";
|
|
53
84
|
import {
|
|
54
85
|
Dialog,
|
|
55
86
|
DialogBody,
|
|
@@ -57,7 +88,7 @@ import {
|
|
|
57
88
|
DialogFooter,
|
|
58
89
|
DialogHeader,
|
|
59
90
|
DialogTitle,
|
|
60
|
-
} from
|
|
91
|
+
} from "../primitives/dialog.tsx";
|
|
61
92
|
import {
|
|
62
93
|
AlertDialog,
|
|
63
94
|
AlertDialogContent,
|
|
@@ -65,33 +96,55 @@ import {
|
|
|
65
96
|
AlertDialogFooter,
|
|
66
97
|
AlertDialogHeader,
|
|
67
98
|
AlertDialogTitle,
|
|
68
|
-
} from
|
|
69
|
-
import {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
import {
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
99
|
+
} from "../primitives/alert-dialog.tsx";
|
|
100
|
+
import {
|
|
101
|
+
InputGroup,
|
|
102
|
+
InputGroupAddon,
|
|
103
|
+
InputGroupButton,
|
|
104
|
+
InputGroupInput,
|
|
105
|
+
} from "../primitives/input-group.tsx";
|
|
106
|
+
import { Separator } from "../primitives/separator.tsx";
|
|
107
|
+
import { Label } from "../primitives/label.tsx";
|
|
108
|
+
import { Search } from "lucide-react";
|
|
109
|
+
import { Select, type SelectOption } from "../primitives/select.tsx";
|
|
110
|
+
import {
|
|
111
|
+
Table,
|
|
112
|
+
TableBody,
|
|
113
|
+
TableCell,
|
|
114
|
+
TableHead,
|
|
115
|
+
TableHeader,
|
|
116
|
+
TableRow,
|
|
117
|
+
} from "../primitives/table.tsx";
|
|
118
|
+
import { ToggleGroup, ToggleGroupItem } from "../primitives/toggle-group.tsx";
|
|
119
|
+
import {
|
|
120
|
+
Tooltip,
|
|
121
|
+
TooltipContent,
|
|
122
|
+
TooltipProvider,
|
|
123
|
+
TooltipTrigger,
|
|
124
|
+
} from "../primitives/tooltip.tsx";
|
|
125
|
+
import { DataState } from "./data-state.tsx";
|
|
78
126
|
|
|
79
127
|
// =============================================================================
|
|
80
128
|
// Helpers
|
|
81
129
|
// =============================================================================
|
|
82
130
|
|
|
83
131
|
/** I18nRef ({ key, default }) | string → texto. */
|
|
84
|
-
function text(m: unknown, fallback =
|
|
85
|
-
if (typeof m ===
|
|
86
|
-
if (m !== null && typeof m ===
|
|
87
|
-
const d = (m as { default: unknown }).default
|
|
88
|
-
if (typeof d ===
|
|
132
|
+
function text(m: unknown, fallback = ""): string {
|
|
133
|
+
if (typeof m === "string") return m;
|
|
134
|
+
if (m !== null && typeof m === "object" && "default" in m) {
|
|
135
|
+
const d = (m as { default: unknown }).default;
|
|
136
|
+
if (typeof d === "string") return d;
|
|
89
137
|
}
|
|
90
|
-
return fallback
|
|
138
|
+
return fallback;
|
|
91
139
|
}
|
|
92
140
|
|
|
93
141
|
function isEmptyValue(v: unknown): boolean {
|
|
94
|
-
return
|
|
142
|
+
return (
|
|
143
|
+
v === undefined ||
|
|
144
|
+
v === null ||
|
|
145
|
+
v === "" ||
|
|
146
|
+
(Array.isArray(v) && v.length === 0)
|
|
147
|
+
);
|
|
95
148
|
}
|
|
96
149
|
|
|
97
150
|
// =============================================================================
|
|
@@ -100,162 +153,180 @@ function isEmptyValue(v: unknown): boolean {
|
|
|
100
153
|
|
|
101
154
|
export interface ActionListColumn<TItem> {
|
|
102
155
|
/** Identifica a coluna (usado como key React). */
|
|
103
|
-
key: string
|
|
156
|
+
key: string;
|
|
104
157
|
/** Cabeçalho da coluna. */
|
|
105
|
-
header: string
|
|
158
|
+
header: string;
|
|
106
159
|
/** Renderiza o conteúdo da célula a partir do item. */
|
|
107
|
-
cell: (item: TItem) => ReactNode
|
|
160
|
+
cell: (item: TItem) => ReactNode;
|
|
108
161
|
/** Largura opcional (CSS class). */
|
|
109
|
-
className?: string
|
|
162
|
+
className?: string;
|
|
110
163
|
/** Header clicável (asc ↔ desc) — escreve `sort` no input. */
|
|
111
|
-
sortable?: boolean
|
|
164
|
+
sortable?: boolean;
|
|
112
165
|
}
|
|
113
166
|
|
|
114
167
|
/** Estado da toolbar (busca + sort + filtros + período + view + colunas visíveis).
|
|
115
168
|
* Controlável de fora pra URL sync — ver listParamsToState/listStateToParams. */
|
|
116
169
|
export interface ActionListState {
|
|
117
|
-
q: string
|
|
118
|
-
sort: SortSpec | null
|
|
119
|
-
filters: Record<string, unknown
|
|
170
|
+
q: string;
|
|
171
|
+
sort: SortSpec | null;
|
|
172
|
+
filters: Record<string, unknown>;
|
|
120
173
|
/** Período ativo: o value de um preset do contrato, ou 'custom'. null = o DEFAULT do
|
|
121
174
|
* contrato (período é recorte obrigatório — não existe "sem período" quando o
|
|
122
175
|
* contrato declara `periods`). Preset fica RELATIVO na URL; materializa no fetch. */
|
|
123
|
-
period: string | null
|
|
176
|
+
period: string | null;
|
|
124
177
|
/** Range do período 'custom' (YYYY-MM-DD). */
|
|
125
|
-
from: string | null
|
|
126
|
-
to: string | null
|
|
178
|
+
from: string | null;
|
|
179
|
+
to: string | null;
|
|
127
180
|
/** View ativa ('table' ou uma chave de `views`). null = a default. */
|
|
128
|
-
view: string | null
|
|
181
|
+
view: string | null;
|
|
129
182
|
/** Colunas visíveis (config de exibição). null = o default do contrato (não-hidden). */
|
|
130
|
-
columns: string[] | null
|
|
183
|
+
columns: string[] | null;
|
|
131
184
|
/** Itens por página (config de exibição). null = o `pageSize` do caller. */
|
|
132
|
-
limit: number | null
|
|
185
|
+
limit: number | null;
|
|
133
186
|
/** Página atual (1-based). Mudar filtro/busca/sort/período volta pra 1. */
|
|
134
|
-
page: number
|
|
187
|
+
page: number;
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
/** Um estado vazio/default — útil pra montar o inicial em quem controla. */
|
|
138
191
|
export function emptyListState(): ActionListState {
|
|
139
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
q: "",
|
|
194
|
+
sort: null,
|
|
195
|
+
filters: {},
|
|
196
|
+
period: null,
|
|
197
|
+
from: null,
|
|
198
|
+
to: null,
|
|
199
|
+
view: null,
|
|
200
|
+
columns: null,
|
|
201
|
+
limit: null,
|
|
202
|
+
page: 1,
|
|
203
|
+
};
|
|
140
204
|
}
|
|
141
205
|
|
|
142
206
|
/** Presets de período que o pattern sabe COMPUTAR (o contrato escolhe quais oferecer
|
|
143
207
|
* via `periods`; o valor materializa em `from`/`to` — YYYY-MM-DD — no fetch). */
|
|
144
|
-
export function presetRange(
|
|
208
|
+
export function presetRange(
|
|
209
|
+
preset: string,
|
|
210
|
+
now = new Date(),
|
|
211
|
+
): { from: string; to: string } | null {
|
|
145
212
|
const iso = (d: Date): string =>
|
|
146
|
-
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2,
|
|
147
|
-
const shift = (days: number): Date =>
|
|
213
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
214
|
+
const shift = (days: number): Date =>
|
|
215
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
|
|
148
216
|
switch (preset) {
|
|
149
|
-
case
|
|
150
|
-
return { from: iso(shift(0)), to: iso(shift(0)) }
|
|
151
|
-
case
|
|
152
|
-
return { from: iso(shift(-1)), to: iso(shift(-1)) }
|
|
153
|
-
case
|
|
154
|
-
return { from: iso(shift(-6)), to: iso(shift(0)) }
|
|
155
|
-
case
|
|
156
|
-
return { from: iso(shift(-29)), to: iso(shift(0)) }
|
|
157
|
-
case
|
|
158
|
-
return {
|
|
159
|
-
|
|
217
|
+
case "today":
|
|
218
|
+
return { from: iso(shift(0)), to: iso(shift(0)) };
|
|
219
|
+
case "yesterday":
|
|
220
|
+
return { from: iso(shift(-1)), to: iso(shift(-1)) };
|
|
221
|
+
case "last7":
|
|
222
|
+
return { from: iso(shift(-6)), to: iso(shift(0)) };
|
|
223
|
+
case "last30":
|
|
224
|
+
return { from: iso(shift(-29)), to: iso(shift(0)) };
|
|
225
|
+
case "thisMonth":
|
|
226
|
+
return {
|
|
227
|
+
from: iso(new Date(now.getFullYear(), now.getMonth(), 1)),
|
|
228
|
+
to: iso(shift(0)),
|
|
229
|
+
};
|
|
230
|
+
case "lastMonth":
|
|
160
231
|
return {
|
|
161
232
|
from: iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
|
|
162
233
|
to: iso(new Date(now.getFullYear(), now.getMonth(), 0)),
|
|
163
|
-
}
|
|
234
|
+
};
|
|
164
235
|
default:
|
|
165
|
-
return null
|
|
236
|
+
return null;
|
|
166
237
|
}
|
|
167
238
|
}
|
|
168
239
|
|
|
169
240
|
/** Estrutural de propósito (como o useListAction): aceita o CONTRATO compartilhado
|
|
170
241
|
* (sem handler) e a action completa — só o que a UI deriva importa aqui. */
|
|
171
242
|
export interface ListActionLike {
|
|
172
|
-
name: string
|
|
173
|
-
kind:
|
|
243
|
+
name: string;
|
|
244
|
+
kind: "list";
|
|
174
245
|
/** Schema de saída (item). Coluna cujo campo carrega a meta de `t.dict` renderiza
|
|
175
246
|
* `DictionaryValue` sem configuração — ver `columnDictionary`. */
|
|
176
|
-
output?: unknown
|
|
177
|
-
columns?: ListColumnSpec[] | undefined
|
|
178
|
-
filters?: Record<string, FilterSpec> | undefined
|
|
179
|
-
sort?: { fields: string[]; default?: SortSpec[] } | undefined
|
|
180
|
-
text?: { fields: string[]; placeholder?: unknown } | undefined
|
|
181
|
-
periods?: PeriodSpec[] | undefined
|
|
247
|
+
output?: unknown;
|
|
248
|
+
columns?: ListColumnSpec[] | undefined;
|
|
249
|
+
filters?: Record<string, FilterSpec> | undefined;
|
|
250
|
+
sort?: { fields: string[]; default?: SortSpec[] } | undefined;
|
|
251
|
+
text?: { fields: string[]; placeholder?: unknown } | undefined;
|
|
252
|
+
periods?: PeriodSpec[] | undefined;
|
|
182
253
|
}
|
|
183
254
|
|
|
184
255
|
/** Estado compartilhado pelos filtros declarativos, independente da listagem. */
|
|
185
256
|
export interface ActionFilterState {
|
|
186
|
-
q: string
|
|
187
|
-
filters: Record<string, unknown
|
|
188
|
-
period: string | null
|
|
189
|
-
from: string | null
|
|
190
|
-
to: string | null
|
|
257
|
+
q: string;
|
|
258
|
+
filters: Record<string, unknown>;
|
|
259
|
+
period: string | null;
|
|
260
|
+
from: string | null;
|
|
261
|
+
to: string | null;
|
|
191
262
|
}
|
|
192
263
|
|
|
193
264
|
export interface ActionFilterBarProps {
|
|
194
|
-
action: Pick<ListActionLike,
|
|
195
|
-
state: ActionFilterState
|
|
196
|
-
onStateChange: (next: ActionFilterState) => void
|
|
197
|
-
filterOptions?: Record<string, SelectOption[]
|
|
198
|
-
onRefresh?: () => Promise<void> | void
|
|
199
|
-
refreshing?: boolean
|
|
265
|
+
action: Pick<ListActionLike, "filters" | "text" | "periods">;
|
|
266
|
+
state: ActionFilterState;
|
|
267
|
+
onStateChange: (next: ActionFilterState) => void;
|
|
268
|
+
filterOptions?: Record<string, SelectOption[]>;
|
|
269
|
+
onRefresh?: () => Promise<void> | void;
|
|
270
|
+
refreshing?: boolean;
|
|
200
271
|
/** Ações de apresentação do consumidor, renderizadas depois do refresh. */
|
|
201
|
-
actions?: ReactNode
|
|
272
|
+
actions?: ReactNode;
|
|
202
273
|
}
|
|
203
274
|
|
|
204
275
|
/** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
|
|
205
276
|
* apresentação é de quem chama; o pattern dá o segment, o estado e os dados. */
|
|
206
277
|
export interface ActionListView<TItem> {
|
|
207
|
-
label: string
|
|
278
|
+
label: string;
|
|
208
279
|
/** Ícone do segment (icon-only, com o label em title/aria). Sem ele, o label vira texto. */
|
|
209
|
-
icon?: ReactNode
|
|
210
|
-
render: (items: TItem[], refetch: () => Promise<void>) => ReactNode
|
|
280
|
+
icon?: ReactNode;
|
|
281
|
+
render: (items: TItem[], refetch: () => Promise<void>) => ReactNode;
|
|
211
282
|
}
|
|
212
283
|
|
|
213
284
|
/** Ação em lote sobre os selecionados. `can` é a FONTE ÚNICA de elegibilidade: governa
|
|
214
285
|
* o checkbox da linha, a coluna de seleção inteira e o que chega no `run` (só os
|
|
215
286
|
* elegíveis). Sem `can` = todo item é elegível. */
|
|
216
287
|
export interface ActionListBatchAction<TItem> {
|
|
217
|
-
label: string
|
|
288
|
+
label: string;
|
|
218
289
|
/** Elegibilidade por item. */
|
|
219
|
-
can?: (item: TItem) => boolean
|
|
290
|
+
can?: (item: TItem) => boolean;
|
|
220
291
|
/** Roda sobre os selecionados ELEGÍVEIS; ao resolver, a seleção limpa e a lista refaz. */
|
|
221
|
-
run: (items: TItem[]) => Promise<void> | void
|
|
292
|
+
run: (items: TItem[]) => Promise<void> | void;
|
|
222
293
|
/** Confirmação antes de rodar. */
|
|
223
|
-
confirm?: { title: string; message?: string }
|
|
224
|
-
destructive?: boolean
|
|
294
|
+
confirm?: { title: string; message?: string };
|
|
295
|
+
destructive?: boolean;
|
|
225
296
|
}
|
|
226
297
|
|
|
227
298
|
export interface ActionListProps<TInput, TItem> {
|
|
228
|
-
action: ListActionLike
|
|
299
|
+
action: ListActionLike;
|
|
229
300
|
/** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
|
|
230
|
-
input: TInput
|
|
301
|
+
input: TInput;
|
|
231
302
|
/** Tabela explícita — sobrepõe as colunas do contrato. */
|
|
232
|
-
columns?: ActionListColumn<TItem>[]
|
|
303
|
+
columns?: ActionListColumn<TItem>[];
|
|
233
304
|
/** Células custom POR CIMA das colunas do contrato (chave = column.key). */
|
|
234
|
-
cells?: Record<string, (item: TItem) => ReactNode
|
|
305
|
+
cells?: Record<string, (item: TItem) => ReactNode>;
|
|
235
306
|
/** Opções de runtime pros filtros select/lookup (chave = nome do filtro). */
|
|
236
|
-
filterOptions?: Record<string, SelectOption[]
|
|
307
|
+
filterOptions?: Record<string, SelectOption[]>;
|
|
237
308
|
/** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
|
|
238
309
|
* tabela ('table', quando há columns) e estas. Ex.: { board: { label, render } }. */
|
|
239
|
-
views?: Record<string, ActionListView<TItem
|
|
310
|
+
views?: Record<string, ActionListView<TItem>>;
|
|
240
311
|
/** Ações em lote (multi-seleção com `can`) — liga a coluna de checkbox na tabela. */
|
|
241
|
-
batch?: ActionListBatchAction<TItem>[]
|
|
312
|
+
batch?: ActionListBatchAction<TItem>[];
|
|
242
313
|
/** Identidade da linha pra seleção. Default: `item.id`. */
|
|
243
|
-
rowId?: (item: TItem) => string
|
|
314
|
+
rowId?: (item: TItem) => string;
|
|
244
315
|
/** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
|
|
245
316
|
* o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
|
|
246
|
-
pageSize?: number
|
|
317
|
+
pageSize?: number;
|
|
247
318
|
/** Mensagem de empty state. Default: "Nenhum resultado." */
|
|
248
|
-
emptyMessage?: string
|
|
319
|
+
emptyMessage?: string;
|
|
249
320
|
/** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
|
|
250
321
|
* como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
|
|
251
|
-
empty?: (items: TItem[]) => boolean
|
|
322
|
+
empty?: (items: TItem[]) => boolean;
|
|
252
323
|
/** Carga EXTRA agregada à do fetch (ex.: a query irmã que os children precisam). */
|
|
253
|
-
loading?: boolean
|
|
324
|
+
loading?: boolean;
|
|
254
325
|
/** Click handler por linha (ex: navegar pro detalhe) — só na tabela. */
|
|
255
|
-
onRowClick?: (item: TItem) => void
|
|
326
|
+
onRowClick?: (item: TItem) => void;
|
|
256
327
|
/** Ações POR LINHA (coluna final, alinhada à direita) — apresentação de quem chama,
|
|
257
328
|
* como `cells`. Cliques ali não disparam o onRowClick. Só na tabela. */
|
|
258
|
-
rowActions?: (item: TItem) => ReactNode
|
|
329
|
+
rowActions?: (item: TItem) => ReactNode;
|
|
259
330
|
/**
|
|
260
331
|
* Conteúdo entre a toolbar e o corpo da listagem — ex.: o resumo do recorte atual
|
|
261
332
|
* ("12 itens, R$ 4.300 no período"), que precisa reagir ao filtro e por isso não cabe
|
|
@@ -265,35 +336,43 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
265
336
|
* enquanto viveu assim, todo bump do Opus o descartava em silêncio e o app parava de
|
|
266
337
|
* compilar sem ninguém entender por quê. Subiu pra cá em 8.6.9.
|
|
267
338
|
*/
|
|
268
|
-
beforeContent?: ReactNode
|
|
339
|
+
beforeContent?: ReactNode;
|
|
269
340
|
/** Modo COMPOSIÇÃO: layout livre ÚNICO (sem segment); toolbar e estados seguem. */
|
|
270
|
-
children?: (items: TItem[], refetch: () => Promise<void>) => ReactNode
|
|
341
|
+
children?: (items: TItem[], refetch: () => Promise<void>) => ReactNode;
|
|
271
342
|
/** Estado da toolbar CONTROLADO (URL sync de quem embala). Default: interno. */
|
|
272
|
-
state?: ActionListState
|
|
273
|
-
onStateChange?: (next: ActionListState) => void
|
|
343
|
+
state?: ActionListState;
|
|
344
|
+
onStateChange?: (next: ActionListState) => void;
|
|
274
345
|
}
|
|
275
346
|
|
|
276
347
|
// =============================================================================
|
|
277
348
|
// Filtro (leaf) — um campo da toolbar/modal, derivado do FilterSpec
|
|
278
349
|
// =============================================================================
|
|
279
350
|
|
|
280
|
-
function optionsFor(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
351
|
+
function optionsFor(
|
|
352
|
+
name: string,
|
|
353
|
+
spec: FilterSpec,
|
|
354
|
+
runtime?: Record<string, SelectOption[]>,
|
|
355
|
+
): SelectOption[] {
|
|
356
|
+
const fromRuntime = runtime?.[name];
|
|
357
|
+
if (fromRuntime !== undefined) return fromRuntime;
|
|
358
|
+
if (spec.options?.kind === "static") {
|
|
359
|
+
return spec.options.items.map((i) => ({
|
|
360
|
+
value: i.value,
|
|
361
|
+
label: text(i.label, i.value),
|
|
362
|
+
}));
|
|
285
363
|
}
|
|
286
|
-
return []
|
|
364
|
+
return [];
|
|
287
365
|
}
|
|
288
366
|
|
|
289
367
|
/** Desce wrappers (optional/nullable/default) até o schema do campo. Estrutural, sem
|
|
290
368
|
* `instanceof`, pra funcionar com as representações v3 e v4 do Zod. */
|
|
291
369
|
function innerSchema(schema: object): object {
|
|
292
|
-
let current = schema
|
|
370
|
+
let current = schema;
|
|
293
371
|
for (;;) {
|
|
294
|
-
const inner = (current as { _def?: { innerType?: unknown } })._def
|
|
295
|
-
|
|
296
|
-
|
|
372
|
+
const inner = (current as { _def?: { innerType?: unknown } })._def
|
|
373
|
+
?.innerType;
|
|
374
|
+
if (inner === null || typeof inner !== "object") return current;
|
|
375
|
+
current = inner;
|
|
297
376
|
}
|
|
298
377
|
}
|
|
299
378
|
|
|
@@ -306,28 +385,33 @@ function columnDictionary(
|
|
|
306
385
|
dicts: Record<string, DictLike>,
|
|
307
386
|
): DictionaryDescriptor | undefined {
|
|
308
387
|
if (column.dictionary !== undefined) {
|
|
309
|
-
const dict = dicts[column.dictionary]
|
|
310
|
-
if (dict === undefined) return undefined
|
|
311
|
-
const fromMeta = dictionaryDescriptor(dict)
|
|
312
|
-
if (fromMeta !== undefined) return fromMeta
|
|
388
|
+
const dict = dicts[column.dictionary];
|
|
389
|
+
if (dict === undefined) return undefined;
|
|
390
|
+
const fromMeta = dictionaryDescriptor(dict);
|
|
391
|
+
if (fromMeta !== undefined) return fromMeta;
|
|
313
392
|
// Dict-like custom (só `options()`): rótulos sem papel de apresentação.
|
|
314
|
-
const options = dict.options()
|
|
393
|
+
const options = dict.options();
|
|
315
394
|
return {
|
|
316
395
|
keys: options.map((o) => o.value),
|
|
317
|
-
entries: Object.fromEntries(
|
|
318
|
-
|
|
396
|
+
entries: Object.fromEntries(
|
|
397
|
+
options.map((o) => [o.value, { ...o, label: o.label }]),
|
|
398
|
+
),
|
|
399
|
+
};
|
|
319
400
|
}
|
|
320
|
-
const field = outputShape?.[column.key]
|
|
321
|
-
if (field === null || typeof field !==
|
|
322
|
-
return dictionaryDescriptor(getLogicalType(innerSchema(field)))
|
|
401
|
+
const field = outputShape?.[column.key];
|
|
402
|
+
if (field === null || typeof field !== "object") return undefined;
|
|
403
|
+
return dictionaryDescriptor(getLogicalType(innerSchema(field)));
|
|
323
404
|
}
|
|
324
405
|
|
|
325
406
|
/** Opções de `options: { kind: 'dictionary', ref }` via dicts do provider. */
|
|
326
|
-
function dictOptionsFor(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
407
|
+
function dictOptionsFor(
|
|
408
|
+
spec: FilterSpec,
|
|
409
|
+
dicts: Record<string, DictLike>,
|
|
410
|
+
): SelectOption[] {
|
|
411
|
+
if (spec.options?.kind !== "dictionary") return [];
|
|
412
|
+
const dict = dicts[spec.options.ref];
|
|
413
|
+
if (dict === undefined) return [];
|
|
414
|
+
return dict.options().map((o) => ({ value: o.value, label: o.label }));
|
|
331
415
|
}
|
|
332
416
|
|
|
333
417
|
/** Opções vindas de uma action de lookup (`options.kind: 'lookup'`): o Select chama
|
|
@@ -336,23 +420,31 @@ function dictOptionsFor(spec: FilterSpec, dicts: Record<string, DictLike>): Sele
|
|
|
336
420
|
function useLookupOptions(
|
|
337
421
|
spec: FilterSpec,
|
|
338
422
|
allValues: Record<string, unknown>,
|
|
339
|
-
): {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
423
|
+
): {
|
|
424
|
+
options: SelectOption[];
|
|
425
|
+
loading: boolean;
|
|
426
|
+
onSearch?: (q: string) => void;
|
|
427
|
+
} {
|
|
428
|
+
const lookup = spec.options?.kind === "lookup" ? spec.options : undefined;
|
|
429
|
+
const search = useLookupAction<Record<string, unknown>, SelectOption>({
|
|
430
|
+
name: lookup?.source ?? "noop",
|
|
431
|
+
kind: "list",
|
|
432
|
+
} as unknown as ActionDef);
|
|
433
|
+
if (lookup === undefined) return { options: [], loading: false };
|
|
434
|
+
const depInput: Record<string, unknown> = {};
|
|
346
435
|
for (const d of lookup.depends ?? []) {
|
|
347
|
-
if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d]
|
|
436
|
+
if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d];
|
|
348
437
|
}
|
|
349
438
|
return {
|
|
350
|
-
options: (search.items ?? []).map((i) => ({
|
|
439
|
+
options: (search.items ?? []).map((i) => ({
|
|
440
|
+
value: i.value,
|
|
441
|
+
label: i.label,
|
|
442
|
+
})),
|
|
351
443
|
loading: search.isLoading,
|
|
352
444
|
onSearch: (q: string) => {
|
|
353
|
-
void search.run({ ...(q !==
|
|
445
|
+
void search.run({ ...(q !== "" ? { q } : {}), ...depInput });
|
|
354
446
|
},
|
|
355
|
-
}
|
|
447
|
+
};
|
|
356
448
|
}
|
|
357
449
|
|
|
358
450
|
function FilterField({
|
|
@@ -364,28 +456,30 @@ function FilterField({
|
|
|
364
456
|
allValues,
|
|
365
457
|
className,
|
|
366
458
|
}: {
|
|
367
|
-
name: string
|
|
368
|
-
spec: FilterSpec
|
|
369
|
-
value: unknown
|
|
370
|
-
onChange: (v: unknown) => void
|
|
371
|
-
options: SelectOption[]
|
|
459
|
+
name: string;
|
|
460
|
+
spec: FilterSpec;
|
|
461
|
+
value: unknown;
|
|
462
|
+
onChange: (v: unknown) => void;
|
|
463
|
+
options: SelectOption[];
|
|
372
464
|
/** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
|
|
373
|
-
allValues: Record<string, unknown
|
|
465
|
+
allValues: Record<string, unknown>;
|
|
374
466
|
/** Largura do contexto: inline, cada tipo tem largura fixa e previsível (select `w-40`,
|
|
375
467
|
* lookup e múltiplo `w-52`, texto `w-40`); nunca cresce pelo conteúdo. O modal manda
|
|
376
468
|
* `w-full`, que vence pelo merge de classes. */
|
|
377
|
-
className?: string
|
|
469
|
+
className?: string;
|
|
378
470
|
}): React.ReactElement {
|
|
379
|
-
const label = text(spec.label, name)
|
|
380
|
-
const placeholder = text(spec.placeholder, label)
|
|
471
|
+
const label = text(spec.label, name);
|
|
472
|
+
const placeholder = text(spec.placeholder, label);
|
|
381
473
|
// Dependência não satisfeita = controle desabilitado (o valor cascateia fora no pai).
|
|
382
|
-
const enabled = (spec.depends ?? []).every(
|
|
383
|
-
|
|
384
|
-
|
|
474
|
+
const enabled = (spec.depends ?? []).every(
|
|
475
|
+
(d) => !isEmptyValue(allValues[d]),
|
|
476
|
+
);
|
|
477
|
+
const lookup = useLookupOptions(spec, allValues);
|
|
478
|
+
const dicts = useDicts();
|
|
385
479
|
// Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
|
|
386
480
|
// sem nenhuma, as do lookup (server-side).
|
|
387
|
-
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts)
|
|
388
|
-
const effOptions = base.length > 0 ? base : lookup.options
|
|
481
|
+
const base = options.length > 0 ? options : dictOptionsFor(spec, dicts);
|
|
482
|
+
const effOptions = base.length > 0 ? base : lookup.options;
|
|
389
483
|
|
|
390
484
|
if (spec.multiple === true) {
|
|
391
485
|
return (
|
|
@@ -398,13 +492,15 @@ function FilterField({
|
|
|
398
492
|
onChange={(v) => onChange(v)}
|
|
399
493
|
options={effOptions}
|
|
400
494
|
placeholder={placeholder}
|
|
401
|
-
className={cn(
|
|
495
|
+
className={cn("w-52", className)}
|
|
402
496
|
disabled={!enabled}
|
|
403
|
-
{...(lookup.onSearch !== undefined
|
|
497
|
+
{...(lookup.onSearch !== undefined
|
|
498
|
+
? { onSearch: lookup.onSearch, loading: lookup.loading }
|
|
499
|
+
: {})}
|
|
404
500
|
/>
|
|
405
|
-
)
|
|
501
|
+
);
|
|
406
502
|
}
|
|
407
|
-
if (spec.type ===
|
|
503
|
+
if (spec.type === "lookup") {
|
|
408
504
|
// Lookup é typeahead por natureza: Select searchable single (server-side quando a origem
|
|
409
505
|
// é uma action; client quando as opções vieram estáticas/de runtime).
|
|
410
506
|
return (
|
|
@@ -412,45 +508,53 @@ function FilterField({
|
|
|
412
508
|
searchable
|
|
413
509
|
clearable
|
|
414
510
|
id={`filter-${name}`}
|
|
415
|
-
value={(value as string | undefined) ??
|
|
511
|
+
value={(value as string | undefined) ?? ""}
|
|
416
512
|
onChange={(v) => onChange(v)}
|
|
417
513
|
options={effOptions}
|
|
418
514
|
placeholder={placeholder}
|
|
419
|
-
className={cn(
|
|
515
|
+
className={cn("w-52", className)}
|
|
420
516
|
disabled={!enabled}
|
|
421
|
-
{...(lookup.onSearch !== undefined
|
|
517
|
+
{...(lookup.onSearch !== undefined
|
|
518
|
+
? { onSearch: lookup.onSearch, loading: lookup.loading }
|
|
519
|
+
: {})}
|
|
422
520
|
/>
|
|
423
|
-
)
|
|
521
|
+
);
|
|
424
522
|
}
|
|
425
|
-
if (spec.type ===
|
|
523
|
+
if (spec.type === "select") {
|
|
426
524
|
// "Todos" é o valor-vazio EXPLÍCITO (limpa o filtro por dentro, sem chip); o
|
|
427
525
|
// `clearable` dá o mesmo destino num clique, sem abrir a lista.
|
|
428
|
-
const ALL =
|
|
526
|
+
const ALL = "__all__";
|
|
429
527
|
return (
|
|
430
528
|
<Select
|
|
431
529
|
clearable
|
|
432
530
|
id={`filter-${name}`}
|
|
433
|
-
value={
|
|
434
|
-
|
|
435
|
-
|
|
531
|
+
value={
|
|
532
|
+
((value as string | undefined) ?? "") === "" ? ALL : (value as string)
|
|
533
|
+
}
|
|
534
|
+
onChange={(v) => onChange(v === ALL ? "" : v)}
|
|
535
|
+
options={[
|
|
536
|
+
{ value: ALL, label: text(spec.placeholder, "Todos") },
|
|
537
|
+
...base,
|
|
538
|
+
]}
|
|
436
539
|
disabled={!enabled}
|
|
437
|
-
className={cn(
|
|
540
|
+
className={cn("w-40", className)}
|
|
438
541
|
/>
|
|
439
|
-
)
|
|
542
|
+
);
|
|
440
543
|
}
|
|
441
544
|
// text · number · date: aplica no blur/Enter (texto) ou no change (date).
|
|
442
|
-
const type =
|
|
545
|
+
const type =
|
|
546
|
+
spec.type === "number" ? "number" : spec.type === "date" ? "date" : "text";
|
|
443
547
|
return (
|
|
444
548
|
<ClearableInput
|
|
445
549
|
id={`filter-${name}`}
|
|
446
550
|
type={type}
|
|
447
|
-
applied={(value as string | undefined) ??
|
|
551
|
+
applied={(value as string | undefined) ?? ""}
|
|
448
552
|
placeholder={placeholder}
|
|
449
|
-
className={cn(
|
|
553
|
+
className={cn("w-40", className)}
|
|
450
554
|
disabled={!enabled}
|
|
451
555
|
onApply={(v) => onChange(v)}
|
|
452
556
|
/>
|
|
453
|
-
)
|
|
557
|
+
);
|
|
454
558
|
}
|
|
455
559
|
|
|
456
560
|
/** Input com limpar (a receita clearable dos textos): Input/InputGroup são locked, o X
|
|
@@ -466,23 +570,24 @@ function ClearableInput({
|
|
|
466
570
|
disabled,
|
|
467
571
|
onApply,
|
|
468
572
|
}: {
|
|
469
|
-
id?: string
|
|
470
|
-
type?: string
|
|
471
|
-
applied: string
|
|
472
|
-
placeholder?: string
|
|
473
|
-
className?: string
|
|
573
|
+
id?: string;
|
|
574
|
+
type?: string;
|
|
575
|
+
applied: string;
|
|
576
|
+
placeholder?: string;
|
|
577
|
+
className?: string;
|
|
474
578
|
/** Addon à esquerda (ex.: a lupa da busca). */
|
|
475
|
-
leading?: ReactNode
|
|
476
|
-
disabled?: boolean
|
|
477
|
-
onApply: (v: string) => void
|
|
579
|
+
leading?: ReactNode;
|
|
580
|
+
disabled?: boolean;
|
|
581
|
+
onApply: (v: string) => void;
|
|
478
582
|
}): React.ReactElement {
|
|
479
|
-
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
480
|
-
const [hasText, setHasText] = useState(applied !==
|
|
583
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
584
|
+
const [hasText, setHasText] = useState(applied !== "");
|
|
481
585
|
// Valor aplicado mudou por fora (chip removido, URL) → re-sincroniza o campo.
|
|
482
586
|
useEffect(() => {
|
|
483
|
-
if (inputRef.current !== null && inputRef.current.value !== applied)
|
|
484
|
-
|
|
485
|
-
|
|
587
|
+
if (inputRef.current !== null && inputRef.current.value !== applied)
|
|
588
|
+
inputRef.current.value = applied;
|
|
589
|
+
setHasText(applied !== "");
|
|
590
|
+
}, [applied]);
|
|
486
591
|
return (
|
|
487
592
|
<InputGroup className={className}>
|
|
488
593
|
{leading !== undefined && <InputGroupAddon>{leading}</InputGroupAddon>}
|
|
@@ -494,14 +599,14 @@ function ClearableInput({
|
|
|
494
599
|
disabled={disabled}
|
|
495
600
|
defaultValue={applied}
|
|
496
601
|
placeholder={placeholder}
|
|
497
|
-
onChange={(e) => setHasText(e.target.value !==
|
|
602
|
+
onChange={(e) => setHasText(e.target.value !== "")}
|
|
498
603
|
onBlur={(e) => {
|
|
499
|
-
if (e.target.value !== applied) onApply(e.target.value)
|
|
604
|
+
if (e.target.value !== applied) onApply(e.target.value);
|
|
500
605
|
}}
|
|
501
606
|
onKeyDown={(e) => {
|
|
502
|
-
if (e.key ===
|
|
503
|
-
e.preventDefault()
|
|
504
|
-
onApply((e.target as HTMLInputElement).value)
|
|
607
|
+
if (e.key === "Enter") {
|
|
608
|
+
e.preventDefault();
|
|
609
|
+
onApply((e.target as HTMLInputElement).value);
|
|
505
610
|
}
|
|
506
611
|
}}
|
|
507
612
|
/>
|
|
@@ -510,9 +615,9 @@ function ClearableInput({
|
|
|
510
615
|
<InputGroupButton
|
|
511
616
|
aria-label="Limpar"
|
|
512
617
|
onClick={() => {
|
|
513
|
-
if (inputRef.current !== null) inputRef.current.value =
|
|
514
|
-
setHasText(false)
|
|
515
|
-
onApply(
|
|
618
|
+
if (inputRef.current !== null) inputRef.current.value = "";
|
|
619
|
+
setHasText(false);
|
|
620
|
+
onApply("");
|
|
516
621
|
}}
|
|
517
622
|
>
|
|
518
623
|
<X className="size-3.5" />
|
|
@@ -520,22 +625,30 @@ function ClearableInput({
|
|
|
520
625
|
</InputGroupAddon>
|
|
521
626
|
)}
|
|
522
627
|
</InputGroup>
|
|
523
|
-
)
|
|
628
|
+
);
|
|
524
629
|
}
|
|
525
630
|
|
|
526
631
|
/** Janela de páginas do pager: vizinhas da atual + pontas, com reticências nos vãos. */
|
|
527
|
-
function pageWindow(current: number, pages: number): Array<number |
|
|
528
|
-
if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1)
|
|
529
|
-
const wanted = new Set<number>([
|
|
632
|
+
function pageWindow(current: number, pages: number): Array<number | "…"> {
|
|
633
|
+
if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
|
|
634
|
+
const wanted = new Set<number>([
|
|
635
|
+
1,
|
|
636
|
+
2,
|
|
637
|
+
3,
|
|
638
|
+
current - 1,
|
|
639
|
+
current,
|
|
640
|
+
current + 1,
|
|
641
|
+
pages,
|
|
642
|
+
]);
|
|
530
643
|
const nums = Array.from(wanted)
|
|
531
644
|
.filter((n) => n >= 1 && n <= pages)
|
|
532
|
-
.sort((a, b) => a - b)
|
|
533
|
-
const out: Array<number |
|
|
645
|
+
.sort((a, b) => a - b);
|
|
646
|
+
const out: Array<number | "…"> = [];
|
|
534
647
|
for (const [i, n] of nums.entries()) {
|
|
535
|
-
if (i > 0 && n - (nums[i - 1] as number) > 1) out.push(
|
|
536
|
-
out.push(n)
|
|
648
|
+
if (i > 0 && n - (nums[i - 1] as number) > 1) out.push("…");
|
|
649
|
+
out.push(n);
|
|
537
650
|
}
|
|
538
|
-
return out
|
|
651
|
+
return out;
|
|
539
652
|
}
|
|
540
653
|
|
|
541
654
|
/** Label pequena ACIMA do controle — o estilo da toolbar (Período, Status, …). */
|
|
@@ -544,10 +657,10 @@ function Labeled({
|
|
|
544
657
|
children,
|
|
545
658
|
slot,
|
|
546
659
|
}: {
|
|
547
|
-
label: string
|
|
548
|
-
children: ReactNode
|
|
660
|
+
label: string;
|
|
661
|
+
children: ReactNode;
|
|
549
662
|
/** data-slot do wrapper (ex.: marca os filtros inline pro overflow responsivo). */
|
|
550
|
-
slot?: string
|
|
663
|
+
slot?: string;
|
|
551
664
|
}): React.ReactElement {
|
|
552
665
|
return (
|
|
553
666
|
<div data-slot={slot} className="flex shrink-0 flex-col gap-1">
|
|
@@ -562,7 +675,7 @@ function Labeled({
|
|
|
562
675
|
</span>
|
|
563
676
|
{children}
|
|
564
677
|
</div>
|
|
565
|
-
)
|
|
678
|
+
);
|
|
566
679
|
}
|
|
567
680
|
|
|
568
681
|
// =============================================================================
|
|
@@ -571,8 +684,8 @@ function Labeled({
|
|
|
571
684
|
// =============================================================================
|
|
572
685
|
|
|
573
686
|
function fmtDay(isoDay: string): string {
|
|
574
|
-
const [y, m, d] = isoDay.split(
|
|
575
|
-
return `${d}/${m}/${y?.slice(2)}
|
|
687
|
+
const [y, m, d] = isoDay.split("-");
|
|
688
|
+
return `${d}/${m}/${y?.slice(2)}`;
|
|
576
689
|
}
|
|
577
690
|
|
|
578
691
|
function PeriodControl({
|
|
@@ -582,44 +695,50 @@ function PeriodControl({
|
|
|
582
695
|
to,
|
|
583
696
|
onChange,
|
|
584
697
|
}: {
|
|
585
|
-
periods: PeriodSpec[]
|
|
586
|
-
period: string | null
|
|
587
|
-
from: string | null
|
|
588
|
-
to: string | null
|
|
589
|
-
onChange: (next: {
|
|
698
|
+
periods: PeriodSpec[];
|
|
699
|
+
period: string | null;
|
|
700
|
+
from: string | null;
|
|
701
|
+
to: string | null;
|
|
702
|
+
onChange: (next: {
|
|
703
|
+
period: string | null;
|
|
704
|
+
from: string | null;
|
|
705
|
+
to: string | null;
|
|
706
|
+
}) => void;
|
|
590
707
|
}): React.ReactElement {
|
|
591
|
-
const [open, setOpen] = useState(false)
|
|
592
|
-
const [showCalendar, setShowCalendar] = useState(period ===
|
|
708
|
+
const [open, setOpen] = useState(false);
|
|
709
|
+
const [showCalendar, setShowCalendar] = useState(period === "custom");
|
|
593
710
|
// Seleção em duas pontas na MÃO (onDayClick), fora da lógica de range do
|
|
594
711
|
// react-day-picker: com o range vigente pré-selecionado, o RDP estenderia a
|
|
595
712
|
// seleção antiga em vez de começar outra — e ele devolve from E to já no
|
|
596
713
|
// primeiro clique. Sem draft = mostra o range vigente; o primeiro clique
|
|
597
714
|
// abre a ponta inicial; o segundo completa, aplica e fecha.
|
|
598
|
-
const [draft, setDraft] = useState<
|
|
715
|
+
const [draft, setDraft] = useState<
|
|
716
|
+
{ from: Date | undefined; to?: Date | undefined } | undefined
|
|
717
|
+
>(undefined);
|
|
599
718
|
|
|
600
719
|
const label =
|
|
601
|
-
period ===
|
|
720
|
+
period === "custom"
|
|
602
721
|
? from !== null && to !== null
|
|
603
722
|
? `${fmtDay(from)} – ${fmtDay(to)}`
|
|
604
|
-
:
|
|
605
|
-
: text(periods.find((p) => p.value === period)?.label, period ??
|
|
723
|
+
: "Personalizado"
|
|
724
|
+
: text(periods.find((p) => p.value === period)?.label, period ?? "");
|
|
606
725
|
|
|
607
726
|
const parseDay = (v: string | null): Date | undefined => {
|
|
608
|
-
if (v === null) return undefined
|
|
609
|
-
const [y, m, d] = v.split(
|
|
610
|
-
return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1)
|
|
611
|
-
}
|
|
727
|
+
if (v === null) return undefined;
|
|
728
|
+
const [y, m, d] = v.split("-").map(Number);
|
|
729
|
+
return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1);
|
|
730
|
+
};
|
|
612
731
|
const toDay = (d: Date): string =>
|
|
613
|
-
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2,
|
|
732
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
614
733
|
|
|
615
734
|
return (
|
|
616
735
|
<Popover
|
|
617
736
|
open={open}
|
|
618
737
|
onOpenChange={(o) => {
|
|
619
|
-
setOpen(o)
|
|
738
|
+
setOpen(o);
|
|
620
739
|
if (o) {
|
|
621
|
-
setShowCalendar(period ===
|
|
622
|
-
setDraft(undefined)
|
|
740
|
+
setShowCalendar(period === "custom");
|
|
741
|
+
setDraft(undefined);
|
|
623
742
|
}
|
|
624
743
|
}}
|
|
625
744
|
>
|
|
@@ -631,16 +750,22 @@ function PeriodControl({
|
|
|
631
750
|
</PopoverTrigger>
|
|
632
751
|
<PopoverContent align="start" className="w-auto p-0">
|
|
633
752
|
<div className="flex">
|
|
634
|
-
<div
|
|
753
|
+
<div
|
|
754
|
+
className={cn(
|
|
755
|
+
"flex min-w-36 flex-col gap-0.5 p-2",
|
|
756
|
+
showCalendar && "border-r border-border",
|
|
757
|
+
)}
|
|
758
|
+
>
|
|
635
759
|
{periods.map((p) => (
|
|
636
760
|
<Button
|
|
637
761
|
key={p.value}
|
|
638
762
|
size="sm"
|
|
639
|
-
|
|
763
|
+
context={period === p.value ? "neutral" : "primary"}
|
|
764
|
+
variant={period === p.value ? "solid" : "ghost"}
|
|
640
765
|
className="justify-start"
|
|
641
766
|
onClick={() => {
|
|
642
|
-
onChange({ period: p.value, from: null, to: null })
|
|
643
|
-
setOpen(false)
|
|
767
|
+
onChange({ period: p.value, from: null, to: null });
|
|
768
|
+
setOpen(false);
|
|
644
769
|
}}
|
|
645
770
|
>
|
|
646
771
|
{text(p.label, p.value)}
|
|
@@ -648,11 +773,12 @@ function PeriodControl({
|
|
|
648
773
|
))}
|
|
649
774
|
<Button
|
|
650
775
|
size="sm"
|
|
651
|
-
|
|
776
|
+
context={period === "custom" ? "neutral" : "primary"}
|
|
777
|
+
variant={period === "custom" ? "solid" : "ghost"}
|
|
652
778
|
className="justify-start"
|
|
653
779
|
onClick={() => {
|
|
654
|
-
setShowCalendar(true)
|
|
655
|
-
setDraft(undefined)
|
|
780
|
+
setShowCalendar(true);
|
|
781
|
+
setDraft(undefined);
|
|
656
782
|
}}
|
|
657
783
|
>
|
|
658
784
|
Personalizado
|
|
@@ -671,11 +797,12 @@ function PeriodControl({
|
|
|
671
797
|
onDayClick={(day) => {
|
|
672
798
|
if (draft?.from !== undefined && draft.to === undefined) {
|
|
673
799
|
// Segunda ponta (qualquer ordem): completa, aplica e fecha.
|
|
674
|
-
const [a, b] =
|
|
675
|
-
|
|
676
|
-
|
|
800
|
+
const [a, b] =
|
|
801
|
+
day < draft.from ? [day, draft.from] : [draft.from, day];
|
|
802
|
+
onChange({ period: "custom", from: toDay(a), to: toDay(b) });
|
|
803
|
+
setOpen(false);
|
|
677
804
|
} else {
|
|
678
|
-
setDraft({ from: day, to: undefined })
|
|
805
|
+
setDraft({ from: day, to: undefined });
|
|
679
806
|
}
|
|
680
807
|
}}
|
|
681
808
|
/>
|
|
@@ -683,7 +810,7 @@ function PeriodControl({
|
|
|
683
810
|
</div>
|
|
684
811
|
</PopoverContent>
|
|
685
812
|
</Popover>
|
|
686
|
-
)
|
|
813
|
+
);
|
|
687
814
|
}
|
|
688
815
|
|
|
689
816
|
// =============================================================================
|
|
@@ -699,20 +826,23 @@ function AdvancedFiltersDialog({
|
|
|
699
826
|
filterOptions,
|
|
700
827
|
cascade,
|
|
701
828
|
}: {
|
|
702
|
-
open: boolean
|
|
703
|
-
onOpenChange: (o: boolean) => void
|
|
704
|
-
specs: Array<[string, FilterSpec]
|
|
705
|
-
values: Record<string, unknown
|
|
706
|
-
onApply: (next: Record<string, unknown>) => void
|
|
707
|
-
filterOptions?: Record<string, SelectOption[]> | undefined
|
|
829
|
+
open: boolean;
|
|
830
|
+
onOpenChange: (o: boolean) => void;
|
|
831
|
+
specs: Array<[string, FilterSpec]>;
|
|
832
|
+
values: Record<string, unknown>;
|
|
833
|
+
onApply: (next: Record<string, unknown>) => void;
|
|
834
|
+
filterOptions?: Record<string, SelectOption[]> | undefined;
|
|
708
835
|
/** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
|
|
709
|
-
cascade: (
|
|
836
|
+
cascade: (
|
|
837
|
+
changed: string,
|
|
838
|
+
next: Record<string, unknown>,
|
|
839
|
+
) => Record<string, unknown>;
|
|
710
840
|
}): React.ReactElement {
|
|
711
|
-
const [draft, setDraft] = useState<Record<string, unknown>>(values)
|
|
841
|
+
const [draft, setDraft] = useState<Record<string, unknown>>(values);
|
|
712
842
|
// Reabriu → re-sincroniza o draft com o aplicado.
|
|
713
843
|
useEffect(() => {
|
|
714
|
-
if (open) setDraft(values)
|
|
715
|
-
}, [open, values])
|
|
844
|
+
if (open) setDraft(values);
|
|
845
|
+
}, [open, values]);
|
|
716
846
|
|
|
717
847
|
return (
|
|
718
848
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
@@ -732,7 +862,9 @@ function AdvancedFiltersDialog({
|
|
|
732
862
|
value={draft[name]}
|
|
733
863
|
allValues={draft}
|
|
734
864
|
className="w-full"
|
|
735
|
-
onChange={(v) =>
|
|
865
|
+
onChange={(v) =>
|
|
866
|
+
setDraft((d) => cascade(name, { ...d, [name]: v }))
|
|
867
|
+
}
|
|
736
868
|
options={optionsFor(name, spec, filterOptions)}
|
|
737
869
|
/>
|
|
738
870
|
</div>
|
|
@@ -743,10 +875,10 @@ function AdvancedFiltersDialog({
|
|
|
743
875
|
variant="outline"
|
|
744
876
|
size="sm"
|
|
745
877
|
onClick={() => {
|
|
746
|
-
const cleared = { ...draft }
|
|
747
|
-
for (const [name] of specs) delete cleared[name]
|
|
748
|
-
onApply(cleared)
|
|
749
|
-
onOpenChange(false)
|
|
878
|
+
const cleared = { ...draft };
|
|
879
|
+
for (const [name] of specs) delete cleared[name];
|
|
880
|
+
onApply(cleared);
|
|
881
|
+
onOpenChange(false);
|
|
750
882
|
}}
|
|
751
883
|
>
|
|
752
884
|
Limpar
|
|
@@ -754,8 +886,8 @@ function AdvancedFiltersDialog({
|
|
|
754
886
|
<Button
|
|
755
887
|
size="sm"
|
|
756
888
|
onClick={() => {
|
|
757
|
-
onApply(draft)
|
|
758
|
-
onOpenChange(false)
|
|
889
|
+
onApply(draft);
|
|
890
|
+
onOpenChange(false);
|
|
759
891
|
}}
|
|
760
892
|
>
|
|
761
893
|
Aplicar
|
|
@@ -763,7 +895,7 @@ function AdvancedFiltersDialog({
|
|
|
763
895
|
</DialogFooter>
|
|
764
896
|
</DialogContent>
|
|
765
897
|
</Dialog>
|
|
766
|
-
)
|
|
898
|
+
);
|
|
767
899
|
}
|
|
768
900
|
|
|
769
901
|
/** Barra declarativa compartilhada por ActionList, relatórios e outras superfícies. */
|
|
@@ -776,181 +908,225 @@ export function ActionFilterBar({
|
|
|
776
908
|
refreshing = false,
|
|
777
909
|
actions,
|
|
778
910
|
}: ActionFilterBarProps): React.ReactElement {
|
|
779
|
-
const dicts = useDicts()
|
|
780
|
-
const filterSpecs = Object.entries(action.filters ?? {})
|
|
781
|
-
const inlineSpecs = filterSpecs.filter(([, spec]) => spec.advanced !== true)
|
|
782
|
-
const hasSearch = (action.text?.fields.length ?? 0) > 0
|
|
783
|
-
const periods = action.periods ?? []
|
|
784
|
-
const defaultPeriod =
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const
|
|
911
|
+
const dicts = useDicts();
|
|
912
|
+
const filterSpecs = Object.entries(action.filters ?? {});
|
|
913
|
+
const inlineSpecs = filterSpecs.filter(([, spec]) => spec.advanced !== true);
|
|
914
|
+
const hasSearch = (action.text?.fields.length ?? 0) > 0;
|
|
915
|
+
const periods = action.periods ?? [];
|
|
916
|
+
const defaultPeriod =
|
|
917
|
+
periods.find((period) => period.default === true)?.value ??
|
|
918
|
+
periods[0]?.value ??
|
|
919
|
+
null;
|
|
920
|
+
const activePeriod = state.period ?? defaultPeriod;
|
|
921
|
+
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
922
|
+
const toolbarRowRef = useRef<HTMLDivElement | null>(null);
|
|
923
|
+
const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY);
|
|
789
924
|
|
|
790
|
-
const cascadeClear = (
|
|
925
|
+
const cascadeClear = (
|
|
926
|
+
changed: string,
|
|
927
|
+
next: Record<string, unknown>,
|
|
928
|
+
): Record<string, unknown> => {
|
|
791
929
|
for (const [name, spec] of filterSpecs) {
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
|
|
930
|
+
if (
|
|
931
|
+
spec.depends?.includes(changed) === true &&
|
|
932
|
+
!isEmptyValue(next[name])
|
|
933
|
+
) {
|
|
934
|
+
delete next[name];
|
|
935
|
+
cascadeClear(name, next);
|
|
795
936
|
}
|
|
796
937
|
}
|
|
797
|
-
return next
|
|
798
|
-
}
|
|
938
|
+
return next;
|
|
939
|
+
};
|
|
799
940
|
|
|
800
941
|
useLayoutEffect(() => {
|
|
801
|
-
const row = toolbarRowRef.current
|
|
802
|
-
if (row === null) return
|
|
942
|
+
const row = toolbarRowRef.current;
|
|
943
|
+
if (row === null) return;
|
|
803
944
|
const overflowing = (): boolean =>
|
|
804
945
|
row.scrollWidth > row.clientWidth + 1 ||
|
|
805
|
-
Array.from(row.children).some(
|
|
946
|
+
Array.from(row.children).some(
|
|
947
|
+
(child) => child.scrollWidth > child.clientWidth + 1,
|
|
948
|
+
);
|
|
806
949
|
const compute = (): void => {
|
|
807
|
-
row.classList.remove(
|
|
808
|
-
const nodes = Array.from(
|
|
809
|
-
|
|
810
|
-
|
|
950
|
+
row.classList.remove("flex-wrap");
|
|
951
|
+
const nodes = Array.from(
|
|
952
|
+
row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'),
|
|
953
|
+
);
|
|
954
|
+
for (const node of nodes) node.style.removeProperty("display");
|
|
955
|
+
let count = nodes.length;
|
|
811
956
|
while (count > 0 && overflowing()) {
|
|
812
|
-
count -= 1
|
|
813
|
-
nodes[count]?.style.setProperty(
|
|
957
|
+
count -= 1;
|
|
958
|
+
nodes[count]?.style.setProperty("display", "none");
|
|
814
959
|
}
|
|
815
|
-
if (count === 0 && overflowing()) row.classList.add(
|
|
816
|
-
setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
|
|
817
|
-
}
|
|
818
|
-
compute()
|
|
819
|
-
const observer = new ResizeObserver(compute)
|
|
820
|
-
observer.observe(row)
|
|
821
|
-
return () => observer.disconnect()
|
|
822
|
-
}, [inlineSpecs.length, state.filters, inlineFit])
|
|
960
|
+
if (count === 0 && overflowing()) row.classList.add("flex-wrap");
|
|
961
|
+
setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count);
|
|
962
|
+
};
|
|
963
|
+
compute();
|
|
964
|
+
const observer = new ResizeObserver(compute);
|
|
965
|
+
observer.observe(row);
|
|
966
|
+
return () => observer.disconnect();
|
|
967
|
+
}, [inlineSpecs.length, state.filters, inlineFit]);
|
|
823
968
|
|
|
824
|
-
const overflowSpecs = Number.isFinite(inlineFit)
|
|
969
|
+
const overflowSpecs = Number.isFinite(inlineFit)
|
|
970
|
+
? inlineSpecs.slice(inlineFit)
|
|
971
|
+
: [];
|
|
825
972
|
const modalSpecs = filterSpecs.filter(
|
|
826
|
-
([name, spec]) =>
|
|
827
|
-
|
|
828
|
-
|
|
973
|
+
([name, spec]) =>
|
|
974
|
+
spec.advanced === true ||
|
|
975
|
+
overflowSpecs.some(([overflowName]) => overflowName === name),
|
|
976
|
+
);
|
|
829
977
|
const setFilter = (name: string, value: unknown): void => {
|
|
830
978
|
onStateChange({
|
|
831
979
|
...state,
|
|
832
980
|
filters: cascadeClear(name, { ...state.filters, [name]: value }),
|
|
833
|
-
})
|
|
834
|
-
}
|
|
981
|
+
});
|
|
982
|
+
};
|
|
835
983
|
const chips = modalSpecs
|
|
836
984
|
.filter(([name]) => !isEmptyValue(state.filters[name]))
|
|
837
985
|
.map(([name, spec]) => {
|
|
838
|
-
const raw = state.filters[name]
|
|
839
|
-
const fromProps = optionsFor(name, spec, filterOptions)
|
|
840
|
-
const opts =
|
|
841
|
-
|
|
986
|
+
const raw = state.filters[name];
|
|
987
|
+
const fromProps = optionsFor(name, spec, filterOptions);
|
|
988
|
+
const opts =
|
|
989
|
+
fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts);
|
|
990
|
+
const display = (value: unknown): string =>
|
|
991
|
+
opts.find((option) => option.value === value)?.label ?? String(value);
|
|
842
992
|
return {
|
|
843
993
|
name,
|
|
844
994
|
label: text(spec.label, name),
|
|
845
|
-
value: Array.isArray(raw) ? raw.map(display).join(
|
|
846
|
-
}
|
|
847
|
-
})
|
|
848
|
-
const searchFirst =
|
|
995
|
+
value: Array.isArray(raw) ? raw.map(display).join(", ") : display(raw),
|
|
996
|
+
};
|
|
997
|
+
});
|
|
998
|
+
const searchFirst =
|
|
999
|
+
hasSearch && periods.length === 0 && filterSpecs.length === 0;
|
|
849
1000
|
const searchBox = hasSearch ? (
|
|
850
1001
|
<ClearableInput
|
|
851
1002
|
className="w-64 min-w-36"
|
|
852
1003
|
leading={<Search />}
|
|
853
1004
|
applied={state.q}
|
|
854
|
-
placeholder={text(action.text?.placeholder,
|
|
1005
|
+
placeholder={text(action.text?.placeholder, "Buscar…")}
|
|
855
1006
|
onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
|
|
856
1007
|
/>
|
|
857
|
-
) : null
|
|
1008
|
+
) : null;
|
|
858
1009
|
|
|
859
1010
|
return (
|
|
860
1011
|
<TooltipProvider>
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
<div className="ml-auto flex min-w-0 items-center gap-2">
|
|
899
|
-
{!searchFirst && searchBox}
|
|
900
|
-
{onRefresh !== undefined && (
|
|
1012
|
+
<div data-slot="action-filter-bar" className="space-y-2">
|
|
1013
|
+
<div ref={toolbarRowRef} className="flex items-end gap-3">
|
|
1014
|
+
{searchFirst && searchBox}
|
|
1015
|
+
{periods.length > 0 && (
|
|
1016
|
+
<Labeled label="Período">
|
|
1017
|
+
<PeriodControl
|
|
1018
|
+
periods={periods}
|
|
1019
|
+
period={activePeriod}
|
|
1020
|
+
from={state.from}
|
|
1021
|
+
to={state.to}
|
|
1022
|
+
onChange={(next) =>
|
|
1023
|
+
onStateChange({
|
|
1024
|
+
...state,
|
|
1025
|
+
...next,
|
|
1026
|
+
period: next.period === defaultPeriod ? null : next.period,
|
|
1027
|
+
})
|
|
1028
|
+
}
|
|
1029
|
+
/>
|
|
1030
|
+
</Labeled>
|
|
1031
|
+
)}
|
|
1032
|
+
{inlineSpecs.map(([name, spec]) => (
|
|
1033
|
+
<Labeled
|
|
1034
|
+
key={name}
|
|
1035
|
+
label={text(spec.label, name)}
|
|
1036
|
+
slot="action-filter-inline"
|
|
1037
|
+
>
|
|
1038
|
+
<FilterField
|
|
1039
|
+
name={name}
|
|
1040
|
+
spec={spec}
|
|
1041
|
+
value={state.filters[name]}
|
|
1042
|
+
allValues={state.filters}
|
|
1043
|
+
onChange={(value) => setFilter(name, value)}
|
|
1044
|
+
options={optionsFor(name, spec, filterOptions)}
|
|
1045
|
+
/>
|
|
1046
|
+
</Labeled>
|
|
1047
|
+
))}
|
|
1048
|
+
{modalSpecs.length > 0 && (
|
|
901
1049
|
<Tooltip>
|
|
902
1050
|
<TooltipTrigger asChild>
|
|
903
1051
|
<Button
|
|
904
1052
|
variant="outline"
|
|
1053
|
+
size="icon"
|
|
905
1054
|
className="shrink-0"
|
|
906
|
-
aria-label="
|
|
907
|
-
onClick={() =>
|
|
908
|
-
disabled={refreshing}
|
|
1055
|
+
aria-label="Filtros"
|
|
1056
|
+
onClick={() => setAdvancedOpen(true)}
|
|
909
1057
|
>
|
|
910
|
-
<
|
|
1058
|
+
<Filter className="h-3.5 w-3.5" />
|
|
911
1059
|
</Button>
|
|
912
1060
|
</TooltipTrigger>
|
|
913
|
-
<TooltipContent>
|
|
1061
|
+
<TooltipContent>Filtros</TooltipContent>
|
|
914
1062
|
</Tooltip>
|
|
915
1063
|
)}
|
|
916
|
-
|
|
1064
|
+
<div className="ml-auto flex min-w-0 items-center gap-2">
|
|
1065
|
+
{!searchFirst && searchBox}
|
|
1066
|
+
{onRefresh !== undefined && (
|
|
1067
|
+
<Tooltip>
|
|
1068
|
+
<TooltipTrigger asChild>
|
|
1069
|
+
<Button
|
|
1070
|
+
variant="outline"
|
|
1071
|
+
size="icon"
|
|
1072
|
+
className="shrink-0"
|
|
1073
|
+
aria-label="Recarregar"
|
|
1074
|
+
onClick={() => void onRefresh()}
|
|
1075
|
+
disabled={refreshing}
|
|
1076
|
+
>
|
|
1077
|
+
<RefreshCw
|
|
1078
|
+
className={cn(
|
|
1079
|
+
"h-3.5 w-3.5",
|
|
1080
|
+
refreshing && "animate-spin",
|
|
1081
|
+
)}
|
|
1082
|
+
/>
|
|
1083
|
+
</Button>
|
|
1084
|
+
</TooltipTrigger>
|
|
1085
|
+
<TooltipContent>Recarregar</TooltipContent>
|
|
1086
|
+
</Tooltip>
|
|
1087
|
+
)}
|
|
1088
|
+
{actions}
|
|
1089
|
+
</div>
|
|
917
1090
|
</div>
|
|
1091
|
+
{chips.length > 0 && (
|
|
1092
|
+
<div
|
|
1093
|
+
data-slot="action-filter-chips"
|
|
1094
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
1095
|
+
>
|
|
1096
|
+
{chips.map((chip) => (
|
|
1097
|
+
<Badge key={chip.name} context="neutral" variant="solid" className="gap-1 pr-1">
|
|
1098
|
+
<span className="text-muted-foreground">{chip.label}:</span>{" "}
|
|
1099
|
+
{chip.value}
|
|
1100
|
+
<button
|
|
1101
|
+
type="button"
|
|
1102
|
+
aria-label={`Remover filtro ${chip.label}`}
|
|
1103
|
+
onClick={() => {
|
|
1104
|
+
const next = { ...state.filters };
|
|
1105
|
+
delete next[chip.name];
|
|
1106
|
+
onStateChange({ ...state, filters: next });
|
|
1107
|
+
}}
|
|
1108
|
+
className="rounded-sm text-muted-foreground hover:text-foreground"
|
|
1109
|
+
>
|
|
1110
|
+
<X className="size-3" />
|
|
1111
|
+
</button>
|
|
1112
|
+
</Badge>
|
|
1113
|
+
))}
|
|
1114
|
+
</div>
|
|
1115
|
+
)}
|
|
1116
|
+
{modalSpecs.length > 0 && (
|
|
1117
|
+
<AdvancedFiltersDialog
|
|
1118
|
+
open={advancedOpen}
|
|
1119
|
+
onOpenChange={setAdvancedOpen}
|
|
1120
|
+
specs={modalSpecs}
|
|
1121
|
+
values={state.filters}
|
|
1122
|
+
cascade={cascadeClear}
|
|
1123
|
+
onApply={(filters) => onStateChange({ ...state, filters })}
|
|
1124
|
+
filterOptions={filterOptions}
|
|
1125
|
+
/>
|
|
1126
|
+
)}
|
|
918
1127
|
</div>
|
|
919
|
-
{chips.length > 0 && (
|
|
920
|
-
<div data-slot="action-filter-chips" className="flex flex-wrap items-center gap-1.5">
|
|
921
|
-
{chips.map((chip) => (
|
|
922
|
-
<Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
|
|
923
|
-
<span className="text-muted-foreground">{chip.label}:</span> {chip.value}
|
|
924
|
-
<button
|
|
925
|
-
type="button"
|
|
926
|
-
aria-label={`Remover filtro ${chip.label}`}
|
|
927
|
-
onClick={() => {
|
|
928
|
-
const next = { ...state.filters }
|
|
929
|
-
delete next[chip.name]
|
|
930
|
-
onStateChange({ ...state, filters: next })
|
|
931
|
-
}}
|
|
932
|
-
className="rounded-sm text-muted-foreground hover:text-foreground"
|
|
933
|
-
>
|
|
934
|
-
<X className="size-3" />
|
|
935
|
-
</button>
|
|
936
|
-
</Badge>
|
|
937
|
-
))}
|
|
938
|
-
</div>
|
|
939
|
-
)}
|
|
940
|
-
{modalSpecs.length > 0 && (
|
|
941
|
-
<AdvancedFiltersDialog
|
|
942
|
-
open={advancedOpen}
|
|
943
|
-
onOpenChange={setAdvancedOpen}
|
|
944
|
-
specs={modalSpecs}
|
|
945
|
-
values={state.filters}
|
|
946
|
-
cascade={cascadeClear}
|
|
947
|
-
onApply={(filters) => onStateChange({ ...state, filters })}
|
|
948
|
-
filterOptions={filterOptions}
|
|
949
|
-
/>
|
|
950
|
-
)}
|
|
951
|
-
</div>
|
|
952
1128
|
</TooltipProvider>
|
|
953
|
-
)
|
|
1129
|
+
);
|
|
954
1130
|
}
|
|
955
1131
|
|
|
956
1132
|
// =============================================================================
|
|
@@ -965,9 +1141,9 @@ export function ActionList<TInput, TItem>({
|
|
|
965
1141
|
filterOptions,
|
|
966
1142
|
views,
|
|
967
1143
|
batch,
|
|
968
|
-
rowId = (item: TItem) => String((item as { id?: unknown }).id ??
|
|
1144
|
+
rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
|
|
969
1145
|
pageSize = 50,
|
|
970
|
-
emptyMessage =
|
|
1146
|
+
emptyMessage = "Nenhum resultado.",
|
|
971
1147
|
empty,
|
|
972
1148
|
loading: extraLoading,
|
|
973
1149
|
onRowClick,
|
|
@@ -980,16 +1156,21 @@ export function ActionList<TInput, TItem>({
|
|
|
980
1156
|
// Query-backed (useListAction): busca declarativa por [name, input] E refetch
|
|
981
1157
|
// automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
|
|
982
1158
|
// O input efetivo é computado abaixo; o hook re-busca quando ele muda.
|
|
983
|
-
const hasTable = columns !== undefined || action.columns !== undefined
|
|
1159
|
+
const hasTable = columns !== undefined || action.columns !== undefined;
|
|
984
1160
|
if (children === undefined && !hasTable && views === undefined) {
|
|
985
|
-
throw new Error(
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
"ActionList precisa de children (composição), columns (prop/contrato) ou views.",
|
|
1163
|
+
);
|
|
986
1164
|
}
|
|
987
1165
|
|
|
988
1166
|
// — Estado da toolbar (interno ou controlado) —
|
|
989
|
-
const defaultSort = action.sort?.default?.[0] ?? null
|
|
990
|
-
const [internal, setInternal] = useState<ActionListState>({
|
|
1167
|
+
const defaultSort = action.sort?.default?.[0] ?? null;
|
|
1168
|
+
const [internal, setInternal] = useState<ActionListState>({
|
|
1169
|
+
...emptyListState(),
|
|
1170
|
+
sort: defaultSort,
|
|
1171
|
+
});
|
|
991
1172
|
// Normaliza estado externo montado à mão (chaves novas ausentes = null/default).
|
|
992
|
-
const raw = controlledState ?? internal
|
|
1173
|
+
const raw = controlledState ?? internal;
|
|
993
1174
|
const state: ActionListState = {
|
|
994
1175
|
...raw,
|
|
995
1176
|
period: raw.period ?? null,
|
|
@@ -999,158 +1180,210 @@ export function ActionList<TInput, TItem>({
|
|
|
999
1180
|
columns: raw.columns ?? null,
|
|
1000
1181
|
limit: raw.limit ?? null,
|
|
1001
1182
|
page: raw.page ?? 1,
|
|
1002
|
-
}
|
|
1183
|
+
};
|
|
1003
1184
|
const setState = (next: ActionListState): void => {
|
|
1004
|
-
if (onStateChange !== undefined) onStateChange(next)
|
|
1005
|
-
if (controlledState === undefined) setInternal(next)
|
|
1006
|
-
}
|
|
1185
|
+
if (onStateChange !== undefined) onStateChange(next);
|
|
1186
|
+
if (controlledState === undefined) setInternal(next);
|
|
1187
|
+
};
|
|
1007
1188
|
// Mudou o RECORTE (filtro/busca/sort/período), a página volta pra 1.
|
|
1008
|
-
const setStateResetPage = (next: ActionListState): void =>
|
|
1189
|
+
const setStateResetPage = (next: ActionListState): void =>
|
|
1190
|
+
setState({ ...next, page: 1 });
|
|
1009
1191
|
|
|
1010
|
-
const filterSpecs = Object.entries(action.filters ?? {})
|
|
1011
|
-
const hasSearch = (action.text?.fields.length ?? 0) > 0
|
|
1192
|
+
const filterSpecs = Object.entries(action.filters ?? {});
|
|
1193
|
+
const hasSearch = (action.text?.fields.length ?? 0) > 0;
|
|
1012
1194
|
|
|
1013
1195
|
// — Views: 'table' (quando há colunas) + as nomeadas. O segment só existe com 2+. —
|
|
1014
1196
|
const viewEntries: Array<[string, string, ReactNode?]> = [
|
|
1015
|
-
...(hasTable
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1197
|
+
...(hasTable
|
|
1198
|
+
? ([["table", "Tabela", <Table2 key="t" />]] as Array<
|
|
1199
|
+
[string, string, ReactNode?]
|
|
1200
|
+
>)
|
|
1201
|
+
: []),
|
|
1202
|
+
...Object.entries(views ?? {}).map(
|
|
1203
|
+
([key, v]) => [key, v.label, v.icon] as [string, string, ReactNode?],
|
|
1204
|
+
),
|
|
1205
|
+
];
|
|
1206
|
+
const activeView = state.view ?? viewEntries[0]?.[0] ?? "table";
|
|
1207
|
+
const hasSegment = children === undefined && viewEntries.length > 1;
|
|
1208
|
+
const periods = action.periods ?? [];
|
|
1209
|
+
const hasToolbar =
|
|
1210
|
+
hasSearch ||
|
|
1211
|
+
filterSpecs.length > 0 ||
|
|
1212
|
+
periods.length > 0 ||
|
|
1213
|
+
hasSegment ||
|
|
1214
|
+
hasTable;
|
|
1022
1215
|
|
|
1023
1216
|
// — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
|
|
1024
1217
|
const effectiveInput = useMemo(() => {
|
|
1025
|
-
const filled: Record<string, unknown> = {}
|
|
1218
|
+
const filled: Record<string, unknown> = {};
|
|
1026
1219
|
for (const [name, v] of Object.entries(state.filters)) {
|
|
1027
|
-
if (!isEmptyValue(v)) filled[name] = v
|
|
1220
|
+
if (!isEmptyValue(v)) filled[name] = v;
|
|
1028
1221
|
}
|
|
1029
1222
|
// Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
|
|
1030
1223
|
// range. state.period null = o default do contrato (sempre aplicado).
|
|
1031
|
-
const effectivePeriod =
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1224
|
+
const effectivePeriod =
|
|
1225
|
+
state.period ??
|
|
1226
|
+
action.periods?.find((p) => p.default === true)?.value ??
|
|
1227
|
+
action.periods?.[0]?.value ??
|
|
1228
|
+
null;
|
|
1229
|
+
let range: { from: string; to: string } | null = null;
|
|
1230
|
+
if (
|
|
1231
|
+
effectivePeriod === "custom" &&
|
|
1232
|
+
state.from !== null &&
|
|
1233
|
+
state.to !== null
|
|
1234
|
+
) {
|
|
1235
|
+
range = { from: state.from, to: state.to };
|
|
1236
|
+
} else if (effectivePeriod !== null && effectivePeriod !== "custom") {
|
|
1237
|
+
range = presetRange(effectivePeriod);
|
|
1037
1238
|
}
|
|
1038
1239
|
return {
|
|
1039
1240
|
...(input as Record<string, unknown>),
|
|
1040
1241
|
...filled,
|
|
1041
1242
|
...(range !== null ? range : {}),
|
|
1042
|
-
...(state.q.trim() !==
|
|
1043
|
-
...(state.sort !== null
|
|
1243
|
+
...(state.q.trim() !== "" ? { q: state.q.trim() } : {}),
|
|
1244
|
+
...(state.sort !== null
|
|
1245
|
+
? { sort: `${state.sort.field}:${state.sort.dir}` }
|
|
1246
|
+
: {}),
|
|
1044
1247
|
// Paginação server-driven: o handler implementa OFFSET e devolve `total`.
|
|
1045
1248
|
limit: state.limit ?? pageSize,
|
|
1046
1249
|
page: state.page,
|
|
1047
|
-
} as TInput
|
|
1048
|
-
}, [input, state, pageSize])
|
|
1250
|
+
} as TInput;
|
|
1251
|
+
}, [input, state, pageSize]);
|
|
1049
1252
|
|
|
1050
|
-
const inputKey = JSON.stringify(effectiveInput)
|
|
1051
|
-
const { items, total, isLoading, isFetching, isError, error, refetch } =
|
|
1052
|
-
TItem,
|
|
1053
|
-
|
|
1054
|
-
|
|
1253
|
+
const inputKey = JSON.stringify(effectiveInput);
|
|
1254
|
+
const { items, total, isLoading, isFetching, isError, error, refetch } =
|
|
1255
|
+
useListAction<TItem, Record<string, unknown>>(
|
|
1256
|
+
action as unknown as { name: string; kind: "list" },
|
|
1257
|
+
effectiveInput as Record<string, unknown>,
|
|
1258
|
+
);
|
|
1055
1259
|
// — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
|
|
1056
1260
|
// picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
|
|
1057
|
-
const visibleKeys = state.columns
|
|
1058
|
-
const dicts = useDicts()
|
|
1059
|
-
const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> =
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1261
|
+
const visibleKeys = state.columns;
|
|
1262
|
+
const dicts = useDicts();
|
|
1263
|
+
const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> =
|
|
1264
|
+
useMemo(() => {
|
|
1265
|
+
if (columns !== undefined) return columns;
|
|
1266
|
+
const outputShape = objectSchemaShape(action.output);
|
|
1267
|
+
return (action.columns ?? [])
|
|
1268
|
+
.filter((c) =>
|
|
1269
|
+
visibleKeys !== null
|
|
1270
|
+
? visibleKeys.includes(c.key)
|
|
1271
|
+
: c.hidden !== true,
|
|
1272
|
+
)
|
|
1273
|
+
.map((c) => {
|
|
1274
|
+
// Resolvido uma vez por coluna: não depende do item.
|
|
1275
|
+
const dictionary =
|
|
1276
|
+
cells?.[c.key] === undefined
|
|
1277
|
+
? columnDictionary(c, outputShape, dicts)
|
|
1278
|
+
: undefined;
|
|
1279
|
+
return {
|
|
1280
|
+
key: c.key,
|
|
1281
|
+
header: text(c.label, c.key),
|
|
1282
|
+
sortable: c.sortable === true,
|
|
1283
|
+
spec: c,
|
|
1284
|
+
className: cn(c.fit === true && "w-px whitespace-nowrap"),
|
|
1285
|
+
cell:
|
|
1286
|
+
cells?.[c.key] ??
|
|
1287
|
+
((item: TItem): ReactNode => {
|
|
1288
|
+
const v = (item as Record<string, unknown>)[c.key];
|
|
1289
|
+
// Ausência (null · undefined · '' · só espaços) tem representação padrão; 0 e
|
|
1290
|
+
// false são valores. O contrato troca o significado por `empty`.
|
|
1291
|
+
if (isAbsentValue(v)) {
|
|
1292
|
+
const label = text(c.empty, "");
|
|
1293
|
+
return (
|
|
1294
|
+
<EmptyValue compact {...(label !== "" ? { label } : {})} />
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
if (dictionary !== undefined && typeof v === "string") {
|
|
1298
|
+
// Sem `presentation` declarado o valor é texto (rótulo); `type: 'badge'`
|
|
1299
|
+
// legado mantém o chip outline, agora com o rótulo em vez do código.
|
|
1300
|
+
const legacyBadge =
|
|
1301
|
+
c.type === "badge" && dictionary.presentation === undefined;
|
|
1302
|
+
return (
|
|
1303
|
+
<DictionaryValue
|
|
1304
|
+
dict={dictionary}
|
|
1305
|
+
value={v}
|
|
1306
|
+
{...(legacyBadge ? { variant: "outline" as const } : {})}
|
|
1307
|
+
/>
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
if (c.type === "date") {
|
|
1311
|
+
// Date-only ('YYYY-MM-DD') parseia LOCAL — new Date() iria pra meia-noite
|
|
1312
|
+
// UTC e deslocaria um dia no fuso; ISO completo segue no caminho normal.
|
|
1313
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(v));
|
|
1314
|
+
const d =
|
|
1315
|
+
m !== null
|
|
1316
|
+
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
|
1317
|
+
: new Date(String(v));
|
|
1318
|
+
return new Intl.DateTimeFormat(
|
|
1319
|
+
"pt-BR",
|
|
1320
|
+
c.dateFormat ?? { dateStyle: "short" },
|
|
1321
|
+
).format(d);
|
|
1322
|
+
}
|
|
1323
|
+
if (c.type === "badge")
|
|
1324
|
+
return <Badge variant="outline">{String(v)}</Badge>;
|
|
1325
|
+
return typeof v === "object" ? null : String(v);
|
|
1326
|
+
}),
|
|
1327
|
+
};
|
|
1328
|
+
});
|
|
1329
|
+
}, [columns, action.columns, action.output, cells, visibleKeys, dicts]);
|
|
1110
1330
|
|
|
1111
1331
|
// — Column picker: só faz sentido na tabela derivada do contrato —
|
|
1112
|
-
const pickerColumns = columns === undefined ? (action.columns ?? []) : []
|
|
1113
|
-
const defaultVisible = pickerColumns
|
|
1332
|
+
const pickerColumns = columns === undefined ? (action.columns ?? []) : [];
|
|
1333
|
+
const defaultVisible = pickerColumns
|
|
1334
|
+
.filter((c) => c.hidden !== true)
|
|
1335
|
+
.map((c) => c.key);
|
|
1114
1336
|
const toggleColumn = (key: string): void => {
|
|
1115
|
-
const current = state.columns ?? defaultVisible
|
|
1116
|
-
const next = current.includes(key)
|
|
1337
|
+
const current = state.columns ?? defaultVisible;
|
|
1338
|
+
const next = current.includes(key)
|
|
1339
|
+
? current.filter((k) => k !== key)
|
|
1340
|
+
: [...current, key];
|
|
1117
1341
|
// Voltou ao default → null (URL limpa).
|
|
1118
|
-
const isDefault =
|
|
1119
|
-
|
|
1120
|
-
|
|
1342
|
+
const isDefault =
|
|
1343
|
+
next.length === defaultVisible.length &&
|
|
1344
|
+
defaultVisible.every((k) => next.includes(k));
|
|
1345
|
+
setState({ ...state, columns: isDefault ? null : next });
|
|
1346
|
+
};
|
|
1121
1347
|
|
|
1122
1348
|
const toggleSort = (key: string): void => {
|
|
1123
|
-
const dir =
|
|
1124
|
-
|
|
1125
|
-
|
|
1349
|
+
const dir =
|
|
1350
|
+
state.sort?.field === key && state.sort.dir === "asc" ? "desc" : "asc";
|
|
1351
|
+
setStateResetPage({ ...state, sort: { field: key, dir } });
|
|
1352
|
+
};
|
|
1126
1353
|
|
|
1127
1354
|
// — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
|
|
1128
1355
|
// elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
|
|
1129
1356
|
// (não vai pra URL) e limpa quando o recorte/página muda ou um batch roda.
|
|
1130
|
-
const batchActions = batch ?? []
|
|
1131
|
-
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
1132
|
-
const [running, setRunning] = useState<string | null>(null)
|
|
1133
|
-
const [confirming, setConfirming] =
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1357
|
+
const batchActions = batch ?? [];
|
|
1358
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
1359
|
+
const [running, setRunning] = useState<string | null>(null);
|
|
1360
|
+
const [confirming, setConfirming] =
|
|
1361
|
+
useState<ActionListBatchAction<TItem> | null>(null);
|
|
1362
|
+
const selectable = (item: TItem): boolean =>
|
|
1363
|
+
batchActions.some((a) => a.can?.(item) ?? true);
|
|
1364
|
+
const selectableItems =
|
|
1365
|
+
batchActions.length > 0 ? items.filter(selectable) : [];
|
|
1366
|
+
const hasSelection =
|
|
1367
|
+
batchActions.length > 0 &&
|
|
1368
|
+
selectableItems.length > 0 &&
|
|
1369
|
+
activeView === "table";
|
|
1137
1370
|
useEffect(() => {
|
|
1138
1371
|
// Página/recorte novo → seleção limpa (os ids podem nem estar mais na tela).
|
|
1139
|
-
setSelected(new Set())
|
|
1140
|
-
}, [inputKey])
|
|
1141
|
-
const selectedItems = items.filter((it) => selected.has(rowId(it)))
|
|
1372
|
+
setSelected(new Set());
|
|
1373
|
+
}, [inputKey]);
|
|
1374
|
+
const selectedItems = items.filter((it) => selected.has(rowId(it)));
|
|
1142
1375
|
const eligibleFor = (a: ActionListBatchAction<TItem>): TItem[] =>
|
|
1143
|
-
selectedItems.filter((it) => a.can?.(it) ?? true)
|
|
1376
|
+
selectedItems.filter((it) => a.can?.(it) ?? true);
|
|
1144
1377
|
const runBatch = async (a: ActionListBatchAction<TItem>): Promise<void> => {
|
|
1145
|
-
setRunning(a.label)
|
|
1378
|
+
setRunning(a.label);
|
|
1146
1379
|
try {
|
|
1147
|
-
await a.run(eligibleFor(a))
|
|
1148
|
-
setSelected(new Set())
|
|
1149
|
-
await refetch()
|
|
1380
|
+
await a.run(eligibleFor(a));
|
|
1381
|
+
setSelected(new Set());
|
|
1382
|
+
await refetch();
|
|
1150
1383
|
} finally {
|
|
1151
|
-
setRunning(null)
|
|
1384
|
+
setRunning(null);
|
|
1152
1385
|
}
|
|
1153
|
-
}
|
|
1386
|
+
};
|
|
1154
1387
|
|
|
1155
1388
|
const toolbar = hasToolbar ? (
|
|
1156
1389
|
<div data-slot="action-list-toolbar">
|
|
@@ -1163,194 +1396,238 @@ export function ActionList<TInput, TItem>({
|
|
|
1163
1396
|
refreshing={isLoading}
|
|
1164
1397
|
actions={
|
|
1165
1398
|
<>
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1399
|
+
{hasSegment && (
|
|
1400
|
+
<ToggleGroup
|
|
1401
|
+
type="single"
|
|
1402
|
+
variant="outline"
|
|
1403
|
+
data-slot="action-list-views"
|
|
1404
|
+
className="shrink-0"
|
|
1405
|
+
value={activeView}
|
|
1406
|
+
onValueChange={(key) => {
|
|
1407
|
+
// No single o Radix DESMARCA ao clicar no ativo (chega '') — o segment
|
|
1408
|
+
// de views sempre tem uma ativa, então o vazio não passa.
|
|
1409
|
+
if (key !== "")
|
|
1410
|
+
setState({
|
|
1411
|
+
...state,
|
|
1412
|
+
view: key === viewEntries[0]?.[0] ? null : key,
|
|
1413
|
+
});
|
|
1414
|
+
}}
|
|
1415
|
+
>
|
|
1416
|
+
{viewEntries.map(([key, label, icon]) => (
|
|
1417
|
+
<Tooltip key={key}>
|
|
1418
|
+
<TooltipTrigger asChild>
|
|
1419
|
+
<ToggleGroupItem value={key} aria-label={label}>
|
|
1420
|
+
{icon ?? label}
|
|
1421
|
+
</ToggleGroupItem>
|
|
1422
|
+
</TooltipTrigger>
|
|
1423
|
+
<TooltipContent>{label}</TooltipContent>
|
|
1424
|
+
</Tooltip>
|
|
1425
|
+
))}
|
|
1426
|
+
</ToggleGroup>
|
|
1427
|
+
)}
|
|
1428
|
+
{/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
|
|
1192
1429
|
tabela está ativa, itens por página sempre, e o que vier depois. */}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1430
|
+
<Popover>
|
|
1431
|
+
{/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
|
|
1432
|
+
<Tooltip>
|
|
1433
|
+
<TooltipTrigger asChild>
|
|
1434
|
+
<PopoverTrigger asChild>
|
|
1435
|
+
<Button
|
|
1436
|
+
variant="outline"
|
|
1437
|
+
className="shrink-0"
|
|
1438
|
+
aria-label="Exibição"
|
|
1439
|
+
>
|
|
1440
|
+
<Settings2 className="h-3.5 w-3.5" />
|
|
1441
|
+
</Button>
|
|
1442
|
+
</PopoverTrigger>
|
|
1443
|
+
</TooltipTrigger>
|
|
1444
|
+
<TooltipContent>Exibição</TooltipContent>
|
|
1445
|
+
</Tooltip>
|
|
1446
|
+
<PopoverContent
|
|
1447
|
+
align="end"
|
|
1448
|
+
className="w-60 p-3"
|
|
1449
|
+
data-slot="action-list-display"
|
|
1450
|
+
>
|
|
1451
|
+
<div className="space-y-3">
|
|
1452
|
+
{activeView === "table" && pickerColumns.length > 0 && (
|
|
1453
|
+
<>
|
|
1454
|
+
<div className="space-y-2">
|
|
1455
|
+
<div className="flex items-center justify-between">
|
|
1456
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
1457
|
+
Colunas
|
|
1458
|
+
</span>
|
|
1459
|
+
{state.columns !== null && (
|
|
1460
|
+
<button
|
|
1461
|
+
type="button"
|
|
1462
|
+
onClick={() =>
|
|
1463
|
+
setState({ ...state, columns: null })
|
|
1464
|
+
}
|
|
1465
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
1466
|
+
>
|
|
1467
|
+
<Eraser className="size-3" />
|
|
1468
|
+
Padrão
|
|
1469
|
+
</button>
|
|
1470
|
+
)}
|
|
1471
|
+
</div>
|
|
1472
|
+
<div className="space-y-1.5">
|
|
1473
|
+
{pickerColumns.map((c) => (
|
|
1474
|
+
<label
|
|
1475
|
+
key={c.key}
|
|
1476
|
+
className="flex items-center gap-2 text-sm"
|
|
1477
|
+
>
|
|
1478
|
+
<Checkbox
|
|
1479
|
+
checked={(
|
|
1480
|
+
state.columns ?? defaultVisible
|
|
1481
|
+
).includes(c.key)}
|
|
1482
|
+
onCheckedChange={() => toggleColumn(c.key)}
|
|
1483
|
+
/>
|
|
1484
|
+
{text(c.label, c.key)}
|
|
1485
|
+
</label>
|
|
1486
|
+
))}
|
|
1487
|
+
</div>
|
|
1233
1488
|
</div>
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1489
|
+
<Separator />
|
|
1490
|
+
</>
|
|
1491
|
+
)}
|
|
1492
|
+
<div className="space-y-2">
|
|
1493
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
1494
|
+
Itens por página
|
|
1495
|
+
</span>
|
|
1496
|
+
<Select
|
|
1497
|
+
size="sm"
|
|
1498
|
+
className="w-full"
|
|
1499
|
+
value={String(state.limit ?? pageSize)}
|
|
1500
|
+
onChange={(v) => {
|
|
1501
|
+
const n = Number(v);
|
|
1502
|
+
// O default do caller fica FORA da URL (limit null).
|
|
1503
|
+
setState({
|
|
1504
|
+
...state,
|
|
1505
|
+
limit: n === pageSize ? null : n,
|
|
1506
|
+
page: 1,
|
|
1507
|
+
});
|
|
1508
|
+
}}
|
|
1509
|
+
options={Array.from(new Set([10, 25, 50, 100, pageSize]))
|
|
1510
|
+
.sort((a, b) => a - b)
|
|
1511
|
+
.map((n) => ({ value: String(n), label: String(n) }))}
|
|
1512
|
+
/>
|
|
1513
|
+
</div>
|
|
1253
1514
|
</div>
|
|
1254
|
-
</
|
|
1255
|
-
</
|
|
1256
|
-
</Popover>
|
|
1515
|
+
</PopoverContent>
|
|
1516
|
+
</Popover>
|
|
1257
1517
|
</>
|
|
1258
1518
|
}
|
|
1259
1519
|
/>
|
|
1260
1520
|
</div>
|
|
1261
|
-
) : null
|
|
1521
|
+
) : null;
|
|
1262
1522
|
|
|
1263
1523
|
// — Estados de carga —
|
|
1264
|
-
const busy = isFetching || extraLoading === true
|
|
1265
|
-
const isEmpty = empty !== undefined ? empty(items) : items.length === 0
|
|
1266
|
-
let content: ReactNode
|
|
1524
|
+
const busy = isFetching || extraLoading === true;
|
|
1525
|
+
const isEmpty = empty !== undefined ? empty(items) : items.length === 0;
|
|
1526
|
+
let content: ReactNode;
|
|
1267
1527
|
if (children !== undefined) {
|
|
1268
1528
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
1269
|
-
content = children(items, () => refetch())
|
|
1270
|
-
} else if (activeView !==
|
|
1529
|
+
content = children(items, () => refetch());
|
|
1530
|
+
} else if (activeView !== "table" && views?.[activeView] !== undefined) {
|
|
1271
1531
|
// View alternativa (board/galeria/…): mesma fonte e toolbar, outro renderer.
|
|
1272
|
-
content = views[activeView].render(items, () => refetch())
|
|
1532
|
+
content = views[activeView].render(items, () => refetch());
|
|
1273
1533
|
} else {
|
|
1274
1534
|
content = (
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1535
|
+
<Table variant="framed">
|
|
1536
|
+
<TableHeader>
|
|
1537
|
+
<TableRow>
|
|
1538
|
+
{hasSelection && (
|
|
1539
|
+
<TableHead className="w-px">
|
|
1540
|
+
<Checkbox
|
|
1541
|
+
aria-label="Selecionar todos os elegíveis"
|
|
1542
|
+
checked={
|
|
1543
|
+
selectableItems.length > 0 &&
|
|
1544
|
+
selectableItems.every((it) => selected.has(rowId(it)))
|
|
1545
|
+
}
|
|
1546
|
+
onCheckedChange={(v) =>
|
|
1547
|
+
setSelected(
|
|
1548
|
+
v === true
|
|
1549
|
+
? new Set(selectableItems.map(rowId))
|
|
1550
|
+
: new Set(),
|
|
1551
|
+
)
|
|
1552
|
+
}
|
|
1553
|
+
/>
|
|
1554
|
+
</TableHead>
|
|
1555
|
+
)}
|
|
1556
|
+
{derived.map((col) => (
|
|
1557
|
+
<TableHead key={col.key} className={col.className}>
|
|
1558
|
+
{col.sortable === true ? (
|
|
1559
|
+
<button
|
|
1560
|
+
type="button"
|
|
1561
|
+
onClick={() => toggleSort(col.key)}
|
|
1562
|
+
className="inline-flex items-center gap-1 hover:text-foreground"
|
|
1563
|
+
>
|
|
1564
|
+
{col.header}
|
|
1565
|
+
{state.sort?.field === col.key ? (
|
|
1566
|
+
state.sort.dir === "asc" ? (
|
|
1567
|
+
<ArrowUp className="size-3.5" />
|
|
1568
|
+
) : (
|
|
1569
|
+
<ArrowDown className="size-3.5" />
|
|
1570
|
+
)
|
|
1571
|
+
) : (
|
|
1572
|
+
<ArrowUpDown className="size-3.5 opacity-40" />
|
|
1573
|
+
)}
|
|
1574
|
+
</button>
|
|
1575
|
+
) : (
|
|
1576
|
+
col.header
|
|
1577
|
+
)}
|
|
1578
|
+
</TableHead>
|
|
1579
|
+
))}
|
|
1580
|
+
{rowActions !== undefined && <TableHead className="w-px" />}
|
|
1581
|
+
</TableRow>
|
|
1582
|
+
</TableHeader>
|
|
1583
|
+
<TableBody>
|
|
1584
|
+
{items.map((item, idx) => (
|
|
1585
|
+
<TableRow
|
|
1586
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
1587
|
+
key={idx}
|
|
1588
|
+
className={
|
|
1589
|
+
onRowClick !== undefined ? "cursor-pointer" : undefined
|
|
1590
|
+
}
|
|
1591
|
+
onClick={
|
|
1592
|
+
onRowClick !== undefined ? () => onRowClick(item) : undefined
|
|
1593
|
+
}
|
|
1594
|
+
>
|
|
1278
1595
|
{hasSelection && (
|
|
1279
|
-
<
|
|
1596
|
+
<TableCell
|
|
1597
|
+
className="w-px"
|
|
1598
|
+
onClick={(e) => e.stopPropagation()}
|
|
1599
|
+
>
|
|
1280
1600
|
<Checkbox
|
|
1281
|
-
aria-label="Selecionar
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1601
|
+
aria-label="Selecionar linha"
|
|
1602
|
+
disabled={!selectable(item)}
|
|
1603
|
+
checked={selected.has(rowId(item))}
|
|
1604
|
+
onCheckedChange={(v) => {
|
|
1605
|
+
const next = new Set(selected);
|
|
1606
|
+
if (v === true) next.add(rowId(item));
|
|
1607
|
+
else next.delete(rowId(item));
|
|
1608
|
+
setSelected(next);
|
|
1609
|
+
}}
|
|
1286
1610
|
/>
|
|
1287
|
-
</
|
|
1611
|
+
</TableCell>
|
|
1288
1612
|
)}
|
|
1289
1613
|
{derived.map((col) => (
|
|
1290
|
-
<
|
|
1291
|
-
{col.
|
|
1292
|
-
|
|
1293
|
-
type="button"
|
|
1294
|
-
onClick={() => toggleSort(col.key)}
|
|
1295
|
-
className="inline-flex items-center gap-1 hover:text-foreground"
|
|
1296
|
-
>
|
|
1297
|
-
{col.header}
|
|
1298
|
-
{state.sort?.field === col.key ? (
|
|
1299
|
-
state.sort.dir === 'asc' ? (
|
|
1300
|
-
<ArrowUp className="size-3.5" />
|
|
1301
|
-
) : (
|
|
1302
|
-
<ArrowDown className="size-3.5" />
|
|
1303
|
-
)
|
|
1304
|
-
) : (
|
|
1305
|
-
<ArrowUpDown className="size-3.5 opacity-40" />
|
|
1306
|
-
)}
|
|
1307
|
-
</button>
|
|
1308
|
-
) : (
|
|
1309
|
-
col.header
|
|
1310
|
-
)}
|
|
1311
|
-
</TableHead>
|
|
1614
|
+
<TableCell key={col.key} className={col.className}>
|
|
1615
|
+
{col.cell(item)}
|
|
1616
|
+
</TableCell>
|
|
1312
1617
|
))}
|
|
1313
|
-
{rowActions !== undefined &&
|
|
1618
|
+
{rowActions !== undefined && (
|
|
1619
|
+
<TableCell
|
|
1620
|
+
className="w-px whitespace-nowrap text-right"
|
|
1621
|
+
onClick={(e) => e.stopPropagation()}
|
|
1622
|
+
>
|
|
1623
|
+
{rowActions(item)}
|
|
1624
|
+
</TableCell>
|
|
1625
|
+
)}
|
|
1314
1626
|
</TableRow>
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
// eslint-disable-next-line react/no-array-index-key
|
|
1320
|
-
key={idx}
|
|
1321
|
-
className={onRowClick !== undefined ? 'cursor-pointer' : undefined}
|
|
1322
|
-
onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
|
|
1323
|
-
>
|
|
1324
|
-
{hasSelection && (
|
|
1325
|
-
<TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
|
|
1326
|
-
<Checkbox
|
|
1327
|
-
aria-label="Selecionar linha"
|
|
1328
|
-
disabled={!selectable(item)}
|
|
1329
|
-
checked={selected.has(rowId(item))}
|
|
1330
|
-
onCheckedChange={(v) => {
|
|
1331
|
-
const next = new Set(selected)
|
|
1332
|
-
if (v === true) next.add(rowId(item))
|
|
1333
|
-
else next.delete(rowId(item))
|
|
1334
|
-
setSelected(next)
|
|
1335
|
-
}}
|
|
1336
|
-
/>
|
|
1337
|
-
</TableCell>
|
|
1338
|
-
)}
|
|
1339
|
-
{derived.map((col) => (
|
|
1340
|
-
<TableCell key={col.key} className={col.className}>
|
|
1341
|
-
{col.cell(item)}
|
|
1342
|
-
</TableCell>
|
|
1343
|
-
))}
|
|
1344
|
-
{rowActions !== undefined && (
|
|
1345
|
-
<TableCell className="w-px whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
|
|
1346
|
-
{rowActions(item)}
|
|
1347
|
-
</TableCell>
|
|
1348
|
-
)}
|
|
1349
|
-
</TableRow>
|
|
1350
|
-
))}
|
|
1351
|
-
</TableBody>
|
|
1352
|
-
</Table>
|
|
1353
|
-
)
|
|
1627
|
+
))}
|
|
1628
|
+
</TableBody>
|
|
1629
|
+
</Table>
|
|
1630
|
+
);
|
|
1354
1631
|
}
|
|
1355
1632
|
const body = (
|
|
1356
1633
|
<DataState
|
|
@@ -1362,7 +1639,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1362
1639
|
>
|
|
1363
1640
|
{content}
|
|
1364
1641
|
</DataState>
|
|
1365
|
-
)
|
|
1642
|
+
);
|
|
1366
1643
|
|
|
1367
1644
|
// — Barra de seleção (batch): aparece só com itens marcados —
|
|
1368
1645
|
const selectionBar =
|
|
@@ -1372,39 +1649,49 @@ export function ActionList<TInput, TItem>({
|
|
|
1372
1649
|
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
|
|
1373
1650
|
>
|
|
1374
1651
|
<span>
|
|
1375
|
-
{selected.size} {selected.size === 1 ?
|
|
1652
|
+
{selected.size} {selected.size === 1 ? "selecionado" : "selecionados"}
|
|
1376
1653
|
</span>
|
|
1377
|
-
<Button
|
|
1654
|
+
<Button
|
|
1655
|
+
size="sm"
|
|
1656
|
+
variant="ghost"
|
|
1657
|
+
onClick={() => setSelected(new Set())}
|
|
1658
|
+
>
|
|
1378
1659
|
Limpar
|
|
1379
1660
|
</Button>
|
|
1380
1661
|
<div className="ml-auto flex items-center gap-2">
|
|
1381
1662
|
{batchActions.map((a) => {
|
|
1382
|
-
const eligible = eligibleFor(a).length
|
|
1663
|
+
const eligible = eligibleFor(a).length;
|
|
1383
1664
|
return (
|
|
1384
1665
|
<Button
|
|
1385
1666
|
key={a.label}
|
|
1386
1667
|
size="sm"
|
|
1387
|
-
|
|
1668
|
+
context={a.destructive === true ? "danger" : "primary"}
|
|
1669
|
+
variant={a.destructive === true ? "solid" : "outline"}
|
|
1388
1670
|
disabled={eligible === 0}
|
|
1389
1671
|
busy={running === a.label}
|
|
1390
1672
|
onClick={() => {
|
|
1391
|
-
if (a.confirm !== undefined) setConfirming(a)
|
|
1392
|
-
else void runBatch(a)
|
|
1673
|
+
if (a.confirm !== undefined) setConfirming(a);
|
|
1674
|
+
else void runBatch(a);
|
|
1393
1675
|
}}
|
|
1394
1676
|
>
|
|
1395
1677
|
{a.label}
|
|
1396
|
-
{eligible > 0 && eligible < selected.size
|
|
1678
|
+
{eligible > 0 && eligible < selected.size
|
|
1679
|
+
? ` (${eligible})`
|
|
1680
|
+
: ""}
|
|
1397
1681
|
</Button>
|
|
1398
|
-
)
|
|
1682
|
+
);
|
|
1399
1683
|
})}
|
|
1400
1684
|
</div>
|
|
1401
1685
|
</div>
|
|
1402
|
-
) : null
|
|
1686
|
+
) : null;
|
|
1403
1687
|
|
|
1404
1688
|
// — Rodapé (o estilo da casa): Página X de Y à esquerda · pager CENTRALIZADO ·
|
|
1405
1689
|
// total à direita (grid de 3 colunas com laterais 1fr = centro geométrico) —
|
|
1406
|
-
const pages =
|
|
1407
|
-
|
|
1690
|
+
const pages =
|
|
1691
|
+
total !== undefined
|
|
1692
|
+
? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
|
|
1693
|
+
: 1;
|
|
1694
|
+
const goTo = (page: number): void => setState({ ...state, page });
|
|
1408
1695
|
const footer =
|
|
1409
1696
|
total !== undefined && !isError ? (
|
|
1410
1697
|
<div
|
|
@@ -1429,7 +1716,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1429
1716
|
/>
|
|
1430
1717
|
</PaginationItem>
|
|
1431
1718
|
{pageWindow(state.page, pages).map((n, i) =>
|
|
1432
|
-
n ===
|
|
1719
|
+
n === "…" ? (
|
|
1433
1720
|
// eslint-disable-next-line react/no-array-index-key
|
|
1434
1721
|
<PaginationItem key={`e${i}`}>
|
|
1435
1722
|
<PaginationEllipsis className="size-7" />
|
|
@@ -1460,58 +1747,73 @@ export function ActionList<TInput, TItem>({
|
|
|
1460
1747
|
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
1461
1748
|
{pages <= 1 && <span />}
|
|
1462
1749
|
<span className="justify-self-end">
|
|
1463
|
-
{total} {total === 1 ?
|
|
1750
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
1464
1751
|
</span>
|
|
1465
1752
|
</div>
|
|
1466
|
-
) : null
|
|
1753
|
+
) : null;
|
|
1467
1754
|
|
|
1468
1755
|
return (
|
|
1469
1756
|
<TooltipProvider>
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
<AlertDialog
|
|
1481
|
-
open
|
|
1482
|
-
onOpenChange={(o) => {
|
|
1483
|
-
if (!o) setConfirming(null)
|
|
1484
|
-
}}
|
|
1757
|
+
<div data-action={action.name} className="space-y-3">
|
|
1758
|
+
{toolbar}
|
|
1759
|
+
{beforeContent}
|
|
1760
|
+
{selectionBar}
|
|
1761
|
+
{/* Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
|
|
1762
|
+
<div
|
|
1763
|
+
aria-busy={busy}
|
|
1764
|
+
className={cn(
|
|
1765
|
+
busy && items.length > 0 && "pointer-events-none opacity-60",
|
|
1766
|
+
)}
|
|
1485
1767
|
>
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
<
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1768
|
+
{body}
|
|
1769
|
+
</div>
|
|
1770
|
+
{footer}
|
|
1771
|
+
{confirming !== null && (
|
|
1772
|
+
<AlertDialog
|
|
1773
|
+
open
|
|
1774
|
+
onOpenChange={(o) => {
|
|
1775
|
+
if (!o) setConfirming(null);
|
|
1776
|
+
}}
|
|
1777
|
+
>
|
|
1778
|
+
<AlertDialogContent>
|
|
1779
|
+
<AlertDialogHeader>
|
|
1780
|
+
<AlertDialogTitle>
|
|
1781
|
+
{confirming.confirm?.title ?? confirming.label}
|
|
1782
|
+
</AlertDialogTitle>
|
|
1783
|
+
{confirming.confirm?.message !== undefined && (
|
|
1784
|
+
<AlertDialogDescription>
|
|
1785
|
+
{confirming.confirm.message}
|
|
1786
|
+
</AlertDialogDescription>
|
|
1787
|
+
)}
|
|
1788
|
+
</AlertDialogHeader>
|
|
1789
|
+
<AlertDialogFooter>
|
|
1790
|
+
<Button
|
|
1791
|
+
variant="ghost"
|
|
1792
|
+
size="sm"
|
|
1793
|
+
onClick={() => setConfirming(null)}
|
|
1794
|
+
>
|
|
1795
|
+
Cancelar
|
|
1796
|
+
</Button>
|
|
1797
|
+
<Button
|
|
1798
|
+
size="sm"
|
|
1799
|
+
variant={
|
|
1800
|
+
confirming.destructive === true ? "destructive" : "default"
|
|
1801
|
+
}
|
|
1802
|
+
onClick={() => {
|
|
1803
|
+
const a = confirming;
|
|
1804
|
+
setConfirming(null);
|
|
1805
|
+
void runBatch(a);
|
|
1806
|
+
}}
|
|
1807
|
+
>
|
|
1808
|
+
{confirming.label}
|
|
1809
|
+
</Button>
|
|
1810
|
+
</AlertDialogFooter>
|
|
1811
|
+
</AlertDialogContent>
|
|
1812
|
+
</AlertDialog>
|
|
1813
|
+
)}
|
|
1814
|
+
</div>
|
|
1513
1815
|
</TooltipProvider>
|
|
1514
|
-
)
|
|
1816
|
+
);
|
|
1515
1817
|
}
|
|
1516
1818
|
|
|
1517
1819
|
// =============================================================================
|
|
@@ -1526,64 +1828,81 @@ export function ActionList<TInput, TItem>({
|
|
|
1526
1828
|
// onStateChange={(s) => navigate(`/rota${listStateToParams(s, contract)}`)} />
|
|
1527
1829
|
|
|
1528
1830
|
/** URLSearchParams → estado da toolbar (lê a convenção acima). */
|
|
1529
|
-
export function listParamsToState(
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1831
|
+
export function listParamsToState(
|
|
1832
|
+
params: URLSearchParams,
|
|
1833
|
+
action: ListActionLike,
|
|
1834
|
+
): ActionListState {
|
|
1835
|
+
const defaultSort = action.sort?.default?.[0] ?? null;
|
|
1836
|
+
const sortParam = params.get("sort");
|
|
1837
|
+
let sort: SortSpec | null = defaultSort;
|
|
1533
1838
|
if (sortParam !== null) {
|
|
1534
|
-
const [field, dir] = sortParam.split(
|
|
1535
|
-
if (field !== undefined && field !==
|
|
1839
|
+
const [field, dir] = sortParam.split(":");
|
|
1840
|
+
if (field !== undefined && field !== "")
|
|
1841
|
+
sort = { field, dir: dir === "asc" ? "asc" : "desc" };
|
|
1536
1842
|
}
|
|
1537
|
-
const filters: Record<string, unknown> = {}
|
|
1843
|
+
const filters: Record<string, unknown> = {};
|
|
1538
1844
|
for (const [name, spec] of Object.entries(action.filters ?? {})) {
|
|
1539
|
-
const v = params.get(name)
|
|
1540
|
-
if (v !== null && v !==
|
|
1845
|
+
const v = params.get(name);
|
|
1846
|
+
if (v !== null && v !== "")
|
|
1847
|
+
filters[name] = spec.multiple === true ? v.split(",") : v;
|
|
1541
1848
|
}
|
|
1542
|
-
const cols = params.get(
|
|
1543
|
-
const limitParam = Number(params.get(
|
|
1544
|
-
const page = Number(params.get(
|
|
1849
|
+
const cols = params.get("cols");
|
|
1850
|
+
const limitParam = Number(params.get("limit") ?? "");
|
|
1851
|
+
const page = Number(params.get("page") ?? "1");
|
|
1545
1852
|
// Range direto no param ('period=2026-07-01..2026-07-07') = custom implícito.
|
|
1546
|
-
const periodParam = params.get(
|
|
1547
|
-
const range =
|
|
1853
|
+
const periodParam = params.get("period");
|
|
1854
|
+
const range =
|
|
1855
|
+
periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null;
|
|
1548
1856
|
return {
|
|
1549
|
-
q: params.get(
|
|
1857
|
+
q: params.get("q") ?? "",
|
|
1550
1858
|
sort,
|
|
1551
1859
|
filters,
|
|
1552
|
-
period: range !== null ?
|
|
1553
|
-
from: range?.[1] ?? params.get(
|
|
1554
|
-
to: range?.[2] ?? params.get(
|
|
1555
|
-
view: params.get(
|
|
1556
|
-
columns: cols !== null && cols !==
|
|
1860
|
+
period: range !== null ? "custom" : periodParam,
|
|
1861
|
+
from: range?.[1] ?? params.get("from"),
|
|
1862
|
+
to: range?.[2] ?? params.get("to"),
|
|
1863
|
+
view: params.get("view"),
|
|
1864
|
+
columns: cols !== null && cols !== "" ? cols.split(",") : null,
|
|
1557
1865
|
limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
|
|
1558
1866
|
page: Number.isInteger(page) && page > 1 ? page : 1,
|
|
1559
|
-
}
|
|
1867
|
+
};
|
|
1560
1868
|
}
|
|
1561
1869
|
|
|
1562
1870
|
/** Estado da toolbar → querystring ('' ou '?…'), omitindo defaults (URL limpa). */
|
|
1563
|
-
export function listStateToParams(
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1871
|
+
export function listStateToParams(
|
|
1872
|
+
state: ActionListState,
|
|
1873
|
+
action: ListActionLike,
|
|
1874
|
+
): string {
|
|
1875
|
+
const qs = new URLSearchParams();
|
|
1876
|
+
if (state.q.trim() !== "") qs.set("q", state.q.trim());
|
|
1877
|
+
const d = action.sort?.default?.[0];
|
|
1878
|
+
if (
|
|
1879
|
+
state.sort !== null &&
|
|
1880
|
+
!(
|
|
1881
|
+
d !== undefined &&
|
|
1882
|
+
state.sort.field === d.field &&
|
|
1883
|
+
state.sort.dir === d.dir
|
|
1884
|
+
)
|
|
1885
|
+
) {
|
|
1886
|
+
qs.set("sort", `${state.sort.field}:${state.sort.dir}`);
|
|
1569
1887
|
}
|
|
1570
1888
|
for (const [name, value] of Object.entries(state.filters)) {
|
|
1571
|
-
if (isEmptyValue(value)) continue
|
|
1572
|
-
qs.set(name, Array.isArray(value) ? value.join(
|
|
1889
|
+
if (isEmptyValue(value)) continue;
|
|
1890
|
+
qs.set(name, Array.isArray(value) ? value.join(",") : String(value));
|
|
1573
1891
|
}
|
|
1574
|
-
if (state.period ===
|
|
1892
|
+
if (state.period === "custom") {
|
|
1575
1893
|
// O custom é implícito: o range vai direto no param ('period=from..to').
|
|
1576
|
-
if (state.from !== null && state.to !== null)
|
|
1894
|
+
if (state.from !== null && state.to !== null)
|
|
1895
|
+
qs.set("period", `${state.from}..${state.to}`);
|
|
1577
1896
|
} else if (state.period !== null) {
|
|
1578
1897
|
// Preset é relativo (materializa no fetch).
|
|
1579
|
-
qs.set(
|
|
1898
|
+
qs.set("period", state.period);
|
|
1580
1899
|
}
|
|
1581
|
-
if (state.view !== null) qs.set(
|
|
1582
|
-
if (state.columns !== null) qs.set(
|
|
1583
|
-
if (state.limit !== null) qs.set(
|
|
1584
|
-
if (state.page > 1) qs.set(
|
|
1585
|
-
const s = qs.toString()
|
|
1586
|
-
return s ===
|
|
1900
|
+
if (state.view !== null) qs.set("view", state.view);
|
|
1901
|
+
if (state.columns !== null) qs.set("cols", state.columns.join(","));
|
|
1902
|
+
if (state.limit !== null) qs.set("limit", String(state.limit));
|
|
1903
|
+
if (state.page > 1) qs.set("page", String(state.page));
|
|
1904
|
+
const s = qs.toString();
|
|
1905
|
+
return s === "" ? "" : `?${s}`;
|
|
1587
1906
|
}
|
|
1588
1907
|
|
|
1589
|
-
export type { Paginated, ListAction }
|
|
1908
|
+
export type { Paginated, ListAction };
|