@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/bin/lib/check.mjs +1103 -310
  3. package/bin/lib/copy.mjs +74 -6
  4. package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
  5. package/docs/adr/0004-page-content-state-is-composed.md +65 -0
  6. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +97 -0
  7. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
  8. package/docs/code-style.md +6 -2
  9. package/package.json +1 -1
  10. package/registry/instructions/opus.md +5 -0
  11. package/registry/skills/build-opus-ui/SKILL.md +27 -16
  12. package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
  13. package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
  14. package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
  15. package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
  16. package/registry/templates/app/src/App.tsx +1 -1
  17. package/src/core/dictionary.ts +52 -14
  18. package/src/core/index.ts +10 -0
  19. package/src/core/ui-context.ts +29 -0
  20. package/src/schema/drivers/zod.ts +34 -19
  21. package/src/ui/components/patterns/action-form-card.tsx +18 -12
  22. package/src/ui/components/patterns/confirm.tsx +26 -3
  23. package/src/ui/components/patterns/content-header.tsx +335 -61
  24. package/src/ui/components/patterns/data-state.tsx +23 -10
  25. package/src/ui/components/patterns/form.tsx +1 -1
  26. package/src/ui/components/patterns/list.tsx +1096 -777
  27. package/src/ui/components/patterns/page-state.tsx +115 -0
  28. package/src/ui/components/patterns/page.tsx +231 -41
  29. package/src/ui/components/patterns/sidebar.tsx +354 -80
  30. package/src/ui/components/patterns/trigger.tsx +13 -9
  31. package/src/ui/components/patterns/view.tsx +7 -11
  32. package/src/ui/components/primitives/alert-dialog.tsx +7 -5
  33. package/src/ui/components/primitives/alert.tsx +298 -80
  34. package/src/ui/components/primitives/ask.tsx +2 -1
  35. package/src/ui/components/primitives/badge.tsx +91 -30
  36. package/src/ui/components/primitives/button.tsx +99 -60
  37. package/src/ui/components/primitives/calendar.tsx +39 -39
  38. package/src/ui/components/primitives/card.tsx +96 -23
  39. package/src/ui/components/primitives/detail.tsx +2 -2
  40. package/src/ui/components/primitives/dictionary-value.tsx +9 -14
  41. package/src/ui/components/primitives/dot.tsx +74 -21
  42. package/src/ui/components/primitives/drawer.tsx +33 -20
  43. package/src/ui/components/primitives/field.tsx +4 -4
  44. package/src/ui/components/primitives/item.tsx +137 -81
  45. package/src/ui/components/primitives/menu.tsx +11 -3
  46. package/src/ui/components/primitives/metric-card.tsx +133 -0
  47. package/src/ui/components/primitives/table.tsx +2 -2
  48. package/src/ui/components/primitives/tooltip.tsx +1 -1
  49. package/src/ui/docs/DocBrowser.tsx +3 -3
  50. package/src/ui/docs/changelog.tsx +1 -1
  51. package/src/ui/docs/content/action-form-card.md +1 -1
  52. package/src/ui/docs/content/alert-dialog.md +8 -8
  53. package/src/ui/docs/content/alert.md +54 -23
  54. package/src/ui/docs/content/badge.md +18 -19
  55. package/src/ui/docs/content/button.md +12 -9
  56. package/src/ui/docs/content/card.md +5 -5
  57. package/src/ui/docs/content/content.md +44 -0
  58. package/src/ui/docs/content/customization.md +2 -2
  59. package/src/ui/docs/content/detail.md +5 -2
  60. package/src/ui/docs/content/dialog.md +2 -2
  61. package/src/ui/docs/content/dictionary-value.md +11 -10
  62. package/src/ui/docs/content/dot.md +7 -7
  63. package/src/ui/docs/content/drawer.md +6 -3
  64. package/src/ui/docs/content/field.md +1 -1
  65. package/src/ui/docs/content/input-group.md +3 -2
  66. package/src/ui/docs/content/item.md +47 -21
  67. package/src/ui/docs/content/menu.md +5 -4
  68. package/src/ui/docs/content/metric-card.md +41 -0
  69. package/src/ui/docs/content/page-state.md +45 -0
  70. package/src/ui/docs/content/page.md +48 -10
  71. package/src/ui/docs/content/semantic-context.md +63 -0
  72. package/src/ui/docs/content/sidebar.md +4 -4
  73. package/src/ui/docs/content/skeleton.md +2 -2
  74. package/src/ui/docs/content/table.md +3 -3
  75. package/src/ui/docs/content/tokens.md +28 -0
  76. package/src/ui/docs/content/tooltip.md +15 -1
  77. package/src/ui/docs/doc-client.tsx +2 -2
  78. package/src/ui/docs/registry.tsx +596 -228
  79. package/src/ui/lib/semantic-context.ts +30 -0
  80. package/src/ui/meta.ts +292 -270
  81. package/src/ui/react.tsx +378 -111
  82. 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" (contador de ativos no botão). Aplicados viram CHIPS removíveis.
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 { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
- import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon, Eraser, RefreshCw, Settings2, SlidersHorizontal, Table2, X } from 'lucide-react'
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 '../../../core/index.ts'
33
- import { dictionaryDescriptor, getLogicalType, type DictionaryDescriptor } from '../../../core/index.ts'
34
- import { useDicts, useListAction, useLookupAction, type DictLike } from '../../drivers/react.tsx'
35
- import { cn } from '../../lib/cn.ts'
36
- import { objectSchemaShape } from '../../lib/object-schema.ts'
37
- import { Badge } from '../primitives/badge.tsx'
38
- import { DictionaryValue } from '../primitives/dictionary-value.tsx'
39
- import { EmptyValue, isAbsentValue } from '../primitives/empty-value.tsx'
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 '../primitives/pagination.tsx'
49
- import { Button } from '../primitives/button.tsx'
50
- import { Calendar } from '../primitives/calendar.tsx'
51
- import { Checkbox } from '../primitives/checkbox.tsx'
52
- import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover.tsx'
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 '../primitives/dialog.tsx'
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 '../primitives/alert-dialog.tsx'
69
- import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../primitives/input-group.tsx'
70
- import { Separator } from '../primitives/separator.tsx'
71
- import { Label } from '../primitives/label.tsx'
72
- import { Search } from 'lucide-react'
73
- import { Select, type SelectOption } from '../primitives/select.tsx'
74
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table.tsx'
75
- import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
76
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
77
- import { DataState } from './data-state.tsx'
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 = ''): string {
85
- if (typeof m === 'string') return m
86
- if (m !== null && typeof m === 'object' && 'default' in m) {
87
- const d = (m as { default: unknown }).default
88
- if (typeof d === 'string') return 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 v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
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 { q: '', sort: null, filters: {}, period: null, from: null, to: null, view: null, columns: null, limit: null, page: 1 }
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(preset: string, now = new Date()): { from: string; to: string } | null {
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, '0')}-${String(d.getDate()).padStart(2, '0')}`
147
- const shift = (days: number): Date => new Date(now.getFullYear(), now.getMonth(), now.getDate() + days)
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 'today':
150
- return { from: iso(shift(0)), to: iso(shift(0)) }
151
- case 'yesterday':
152
- return { from: iso(shift(-1)), to: iso(shift(-1)) }
153
- case 'last7':
154
- return { from: iso(shift(-6)), to: iso(shift(0)) }
155
- case 'last30':
156
- return { from: iso(shift(-29)), to: iso(shift(0)) }
157
- case 'thisMonth':
158
- return { from: iso(new Date(now.getFullYear(), now.getMonth(), 1)), to: iso(shift(0)) }
159
- case 'lastMonth':
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: 'list'
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, 'filters' | 'text' | 'periods'>
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(name: string, spec: FilterSpec, runtime?: Record<string, SelectOption[]>): SelectOption[] {
281
- const fromRuntime = runtime?.[name]
282
- if (fromRuntime !== undefined) return fromRuntime
283
- if (spec.options?.kind === 'static') {
284
- return spec.options.items.map((i) => ({ value: i.value, label: text(i.label, i.value) }))
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?.innerType
295
- if (inner === null || typeof inner !== 'object') return current
296
- current = inner
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(options.map((o) => [o.value, { ...o, label: o.label }])),
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 !== 'object') return undefined
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(spec: FilterSpec, dicts: Record<string, DictLike>): SelectOption[] {
327
- if (spec.options?.kind !== 'dictionary') return []
328
- const dict = dicts[spec.options.ref]
329
- if (dict === undefined) return []
330
- return dict.options().map((o) => ({ value: o.value, label: o.label }))
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
- ): { options: SelectOption[]; loading: boolean; onSearch?: (q: string) => void } {
340
- const lookup = spec.options?.kind === 'lookup' ? spec.options : undefined
341
- const search = useLookupAction<Record<string, unknown>, SelectOption>(
342
- { name: lookup?.source ?? 'noop', kind: 'list' } as unknown as ActionDef,
343
- )
344
- if (lookup === undefined) return { options: [], loading: false }
345
- const depInput: Record<string, unknown> = {}
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) => ({ value: i.value, label: i.label })),
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 !== '' ? { q } : {}), ...depInput })
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((d) => !isEmptyValue(allValues[d]))
383
- const lookup = useLookupOptions(spec, allValues)
384
- const dicts = useDicts()
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('w-52', className)}
495
+ className={cn("w-52", className)}
402
496
  disabled={!enabled}
403
- {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
497
+ {...(lookup.onSearch !== undefined
498
+ ? { onSearch: lookup.onSearch, loading: lookup.loading }
499
+ : {})}
404
500
  />
405
- )
501
+ );
406
502
  }
407
- if (spec.type === 'lookup') {
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('w-52', className)}
515
+ className={cn("w-52", className)}
420
516
  disabled={!enabled}
421
- {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
517
+ {...(lookup.onSearch !== undefined
518
+ ? { onSearch: lookup.onSearch, loading: lookup.loading }
519
+ : {})}
422
520
  />
423
- )
521
+ );
424
522
  }
425
- if (spec.type === 'select') {
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 = '__all__'
526
+ const ALL = "__all__";
429
527
  return (
430
528
  <Select
431
529
  clearable
432
530
  id={`filter-${name}`}
433
- value={((value as string | undefined) ?? '') === '' ? ALL : (value as string)}
434
- onChange={(v) => onChange(v === ALL ? '' : v)}
435
- options={[{ value: ALL, label: text(spec.placeholder, 'Todos') }, ...base]}
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('w-40', className)}
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 = spec.type === 'number' ? 'number' : spec.type === 'date' ? 'date' : 'text'
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('w-40', className)}
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) inputRef.current.value = applied
484
- setHasText(applied !== '')
485
- }, [applied])
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 === 'Enter') {
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>([1, 2, 3, current - 1, current, current + 1, pages])
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: { period: string | null; from: string | null; to: string | null }) => void
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 === 'custom')
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<{ from: Date | undefined; to?: Date | undefined } | undefined>(undefined)
715
+ const [draft, setDraft] = useState<
716
+ { from: Date | undefined; to?: Date | undefined } | undefined
717
+ >(undefined);
599
718
 
600
719
  const label =
601
- period === 'custom'
720
+ period === "custom"
602
721
  ? from !== null && to !== null
603
722
  ? `${fmtDay(from)} – ${fmtDay(to)}`
604
- : 'Personalizado'
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('-').map(Number)
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, '0')}-${String(d.getDate()).padStart(2, '0')}`
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 === 'custom')
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 className={cn('flex min-w-36 flex-col gap-0.5 p-2', showCalendar && 'border-r border-border')}>
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
- variant={period === p.value ? 'secondary' : 'ghost'}
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
- variant={period === 'custom' ? 'secondary' : 'ghost'}
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] = day < draft.from ? [day, draft.from] : [draft.from, day]
675
- onChange({ period: 'custom', from: toDay(a), to: toDay(b) })
676
- setOpen(false)
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: (changed: string, next: Record<string, unknown>) => Record<string, unknown>
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) => setDraft((d) => cascade(name, { ...d, [name]: 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 = periods.find((period) => period.default === true)?.value ?? periods[0]?.value ?? null
785
- const activePeriod = state.period ?? defaultPeriod
786
- const [advancedOpen, setAdvancedOpen] = useState(false)
787
- const toolbarRowRef = useRef<HTMLDivElement | null>(null)
788
- const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
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 = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
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 (spec.depends?.includes(changed) === true && !isEmptyValue(next[name])) {
793
- delete next[name]
794
- cascadeClear(name, next)
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((child) => child.scrollWidth > child.clientWidth + 1)
946
+ Array.from(row.children).some(
947
+ (child) => child.scrollWidth > child.clientWidth + 1,
948
+ );
806
949
  const compute = (): void => {
807
- row.classList.remove('flex-wrap')
808
- const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'))
809
- for (const node of nodes) node.style.removeProperty('display')
810
- let count = nodes.length
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('display', 'none')
957
+ count -= 1;
958
+ nodes[count]?.style.setProperty("display", "none");
814
959
  }
815
- if (count === 0 && overflowing()) row.classList.add('flex-wrap')
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) ? inlineSpecs.slice(inlineFit) : []
969
+ const overflowSpecs = Number.isFinite(inlineFit)
970
+ ? inlineSpecs.slice(inlineFit)
971
+ : [];
825
972
  const modalSpecs = filterSpecs.filter(
826
- ([name, spec]) => spec.advanced === true || overflowSpecs.some(([overflowName]) => overflowName === name),
827
- )
828
- const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
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 = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
841
- const display = (value: unknown): string => opts.find((option) => option.value === value)?.label ?? String(value)
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(', ') : display(raw),
846
- }
847
- })
848
- const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
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, 'Buscar…')}
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
- <div data-slot="action-filter-bar" className="space-y-2">
862
- <div ref={toolbarRowRef} className="flex items-end gap-3">
863
- {searchFirst && searchBox}
864
- {periods.length > 0 && (
865
- <Labeled label="Período">
866
- <PeriodControl
867
- periods={periods}
868
- period={activePeriod}
869
- from={state.from}
870
- to={state.to}
871
- onChange={(next) => onStateChange({
872
- ...state,
873
- ...next,
874
- period: next.period === defaultPeriod ? null : next.period,
875
- })}
876
- />
877
- </Labeled>
878
- )}
879
- {inlineSpecs.map(([name, spec]) => (
880
- <Labeled key={name} label={text(spec.label, name)} slot="action-filter-inline">
881
- <FilterField
882
- name={name}
883
- spec={spec}
884
- value={state.filters[name]}
885
- allValues={state.filters}
886
- onChange={(value) => setFilter(name, value)}
887
- options={optionsFor(name, spec, filterOptions)}
888
- />
889
- </Labeled>
890
- ))}
891
- {modalSpecs.length > 0 && (
892
- <Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
893
- <SlidersHorizontal className="h-3.5 w-3.5" />
894
- Filtros
895
- {modalActive > 0 && <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">{modalActive}</Badge>}
896
- </Button>
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="Recarregar"
907
- onClick={() => void onRefresh()}
908
- disabled={refreshing}
1055
+ aria-label="Filtros"
1056
+ onClick={() => setAdvancedOpen(true)}
909
1057
  >
910
- <RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
1058
+ <Filter className="h-3.5 w-3.5" />
911
1059
  </Button>
912
1060
  </TooltipTrigger>
913
- <TooltipContent>Recarregar</TooltipContent>
1061
+ <TooltipContent>Filtros</TooltipContent>
914
1062
  </Tooltip>
915
1063
  )}
916
- {actions}
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 = 'Nenhum resultado.',
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('ActionList precisa de children (composição), columns (prop/contrato) ou views.')
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>({ ...emptyListState(), sort: defaultSort })
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 => setState({ ...next, page: 1 })
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 ? ([['table', 'Tabela', <Table2 key="t" />]] as Array<[string, string, ReactNode?]>) : []),
1016
- ...Object.entries(views ?? {}).map(([key, v]) => [key, v.label, v.icon] as [string, string, ReactNode?]),
1017
- ]
1018
- const activeView = state.view ?? viewEntries[0]?.[0] ?? 'table'
1019
- const hasSegment = children === undefined && viewEntries.length > 1
1020
- const periods = action.periods ?? []
1021
- const hasToolbar = hasSearch || filterSpecs.length > 0 || periods.length > 0 || hasSegment || hasTable
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 = state.period ?? (action.periods?.find((p) => p.default === true)?.value ?? action.periods?.[0]?.value ?? null)
1032
- let range: { from: string; to: string } | null = null
1033
- if (effectivePeriod === 'custom' && state.from !== null && state.to !== null) {
1034
- range = { from: state.from, to: state.to }
1035
- } else if (effectivePeriod !== null && effectivePeriod !== 'custom') {
1036
- range = presetRange(effectivePeriod)
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() !== '' ? { q: state.q.trim() } : {}),
1043
- ...(state.sort !== null ? { sort: `${state.sort.field}:${state.sort.dir}` } : {}),
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 } = useListAction<
1052
- TItem,
1053
- Record<string, unknown>
1054
- >(action as unknown as { name: string; kind: 'list' }, effectiveInput as Record<string, unknown>)
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 }> = useMemo(() => {
1060
- if (columns !== undefined) return columns
1061
- const outputShape = objectSchemaShape(action.output)
1062
- return (action.columns ?? [])
1063
- .filter((c) => (visibleKeys !== null ? visibleKeys.includes(c.key) : c.hidden !== true))
1064
- .map((c) => {
1065
- // Resolvido uma vez por coluna: não depende do item.
1066
- const dictionary = cells?.[c.key] === undefined ? columnDictionary(c, outputShape, dicts) : undefined
1067
- return {
1068
- key: c.key,
1069
- header: text(c.label, c.key),
1070
- sortable: c.sortable === true,
1071
- spec: c,
1072
- className: cn(c.fit === true && 'w-px whitespace-nowrap'),
1073
- cell:
1074
- cells?.[c.key] ??
1075
- ((item: TItem): ReactNode => {
1076
- const v = (item as Record<string, unknown>)[c.key]
1077
- // Ausência (null · undefined · '' · só espaços) tem representação padrão; 0 e
1078
- // false são valores. O contrato troca o significado por `empty`.
1079
- if (isAbsentValue(v)) {
1080
- const label = text(c.empty, '')
1081
- return <EmptyValue compact {...(label !== '' ? { label } : {})} />
1082
- }
1083
- if (dictionary !== undefined && typeof v === 'string') {
1084
- // Sem `presentation` declarado o valor é texto (rótulo); `type: 'badge'`
1085
- // legado mantém o chip outline, agora com o rótulo em vez do código.
1086
- const legacyBadge = c.type === 'badge' && dictionary.presentation === undefined
1087
- return (
1088
- <DictionaryValue
1089
- dict={dictionary}
1090
- value={v}
1091
- {...(legacyBadge ? { variant: 'outline' as const } : {})}
1092
- />
1093
- )
1094
- }
1095
- if (c.type === 'date') {
1096
- // Date-only ('YYYY-MM-DD') parseia LOCAL — new Date() iria pra meia-noite
1097
- // UTC e deslocaria um dia no fuso; ISO completo segue no caminho normal.
1098
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(v))
1099
- const d = m !== null
1100
- ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
1101
- : new Date(String(v))
1102
- return new Intl.DateTimeFormat('pt-BR', c.dateFormat ?? { dateStyle: 'short' }).format(d)
1103
- }
1104
- if (c.type === 'badge') return <Badge variant="outline">{String(v)}</Badge>
1105
- return typeof v === 'object' ? null : String(v)
1106
- }),
1107
- }
1108
- })
1109
- }, [columns, action.columns, action.output, cells, visibleKeys, dicts])
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 · '' · 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.filter((c) => c.hidden !== true).map((c) => c.key)
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) ? current.filter((k) => k !== key) : [...current, 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 = next.length === defaultVisible.length && defaultVisible.every((k) => next.includes(k))
1119
- setState({ ...state, columns: isDefault ? null : next })
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 = state.sort?.field === key && state.sort.dir === 'asc' ? 'desc' : 'asc'
1124
- setStateResetPage({ ...state, sort: { field: key, dir } })
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] = useState<ActionListBatchAction<TItem> | null>(null)
1134
- const selectable = (item: TItem): boolean => batchActions.some((a) => a.can?.(item) ?? true)
1135
- const selectableItems = batchActions.length > 0 ? items.filter(selectable) : []
1136
- const hasSelection = batchActions.length > 0 && selectableItems.length > 0 && activeView === 'table'
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
- {hasSegment && (
1167
- <ToggleGroup
1168
- type="single"
1169
- variant="outline"
1170
- data-slot="action-list-views"
1171
- className="shrink-0"
1172
- value={activeView}
1173
- onValueChange={(key) => {
1174
- // No single o Radix DESMARCA ao clicar no ativo (chega '') — o segment
1175
- // de views sempre tem uma ativa, então o vazio não passa.
1176
- if (key !== '') setState({ ...state, view: key === viewEntries[0]?.[0] ? null : key })
1177
- }}
1178
- >
1179
- {viewEntries.map(([key, label, icon]) => (
1180
- <Tooltip key={key}>
1181
- <TooltipTrigger asChild>
1182
- <ToggleGroupItem value={key} aria-label={label}>
1183
- {icon ?? label}
1184
- </ToggleGroupItem>
1185
- </TooltipTrigger>
1186
- <TooltipContent>{label}</TooltipContent>
1187
- </Tooltip>
1188
- ))}
1189
- </ToggleGroup>
1190
- )}
1191
- {/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
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
- <Popover>
1194
- {/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
1195
- <Tooltip>
1196
- <TooltipTrigger asChild>
1197
- <PopoverTrigger asChild>
1198
- <Button variant="outline" className="shrink-0" aria-label="Exibição">
1199
- <Settings2 className="h-3.5 w-3.5" />
1200
- </Button>
1201
- </PopoverTrigger>
1202
- </TooltipTrigger>
1203
- <TooltipContent>Exibição</TooltipContent>
1204
- </Tooltip>
1205
- <PopoverContent align="end" className="w-60 p-3" data-slot="action-list-display">
1206
- <div className="space-y-3">
1207
- {activeView === 'table' && pickerColumns.length > 0 && (
1208
- <>
1209
- <div className="space-y-2">
1210
- <div className="flex items-center justify-between">
1211
- <span className="text-xs font-medium text-muted-foreground">Colunas</span>
1212
- {state.columns !== null && (
1213
- <button
1214
- type="button"
1215
- onClick={() => setState({ ...state, columns: null })}
1216
- className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
1217
- >
1218
- <Eraser className="size-3" />
1219
- Padrão
1220
- </button>
1221
- )}
1222
- </div>
1223
- <div className="space-y-1.5">
1224
- {pickerColumns.map((c) => (
1225
- <label key={c.key} className="flex items-center gap-2 text-sm">
1226
- <Checkbox
1227
- checked={(state.columns ?? defaultVisible).includes(c.key)}
1228
- onCheckedChange={() => toggleColumn(c.key)}
1229
- />
1230
- {text(c.label, c.key)}
1231
- </label>
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
- </div>
1235
- <Separator />
1236
- </>
1237
- )}
1238
- <div className="space-y-2">
1239
- <span className="text-xs font-medium text-muted-foreground">Itens por página</span>
1240
- <Select
1241
- size="sm"
1242
- className="w-full"
1243
- value={String(state.limit ?? pageSize)}
1244
- onChange={(v) => {
1245
- const n = Number(v)
1246
- // O default do caller fica FORA da URL (limit null).
1247
- setState({ ...state, limit: n === pageSize ? null : n, page: 1 })
1248
- }}
1249
- options={Array.from(new Set([10, 25, 50, 100, pageSize]))
1250
- .sort((a, b) => a - b)
1251
- .map((n) => ({ value: String(n), label: String(n) }))}
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
- </div>
1255
- </PopoverContent>
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 !== 'table' && views?.[activeView] !== undefined) {
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
- <Table variant="framed">
1276
- <TableHeader>
1277
- <TableRow>
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
- <TableHead className="w-px">
1596
+ <TableCell
1597
+ className="w-px"
1598
+ onClick={(e) => e.stopPropagation()}
1599
+ >
1280
1600
  <Checkbox
1281
- aria-label="Selecionar todos os elegíveis"
1282
- checked={selectableItems.length > 0 && selectableItems.every((it) => selected.has(rowId(it)))}
1283
- onCheckedChange={(v) =>
1284
- setSelected(v === true ? new Set(selectableItems.map(rowId)) : new Set())
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
- </TableHead>
1611
+ </TableCell>
1288
1612
  )}
1289
1613
  {derived.map((col) => (
1290
- <TableHead key={col.key} className={col.className}>
1291
- {col.sortable === true ? (
1292
- <button
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 && <TableHead className="w-px" />}
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
- </TableHeader>
1316
- <TableBody>
1317
- {items.map((item, idx) => (
1318
- <TableRow
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 ? 'selecionado' : 'selecionados'}
1652
+ {selected.size} {selected.size === 1 ? "selecionado" : "selecionados"}
1376
1653
  </span>
1377
- <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
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
- variant={a.destructive === true ? 'destructive' : 'outline'}
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 ? ` (${eligible})` : ''}
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 = total !== undefined ? Math.max(1, Math.ceil(total / (state.limit ?? pageSize))) : 1
1407
- const goTo = (page: number): void => setState({ ...state, page })
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 ? 'item' : 'itens'} no total
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
- <div data-action={action.name} className="space-y-3">
1471
- {toolbar}
1472
- {beforeContent}
1473
- {selectionBar}
1474
- {/* Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
1475
- <div aria-busy={busy} className={cn(busy && items.length > 0 && 'pointer-events-none opacity-60')}>
1476
- {body}
1477
- </div>
1478
- {footer}
1479
- {confirming !== null && (
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
- <AlertDialogContent>
1487
- <AlertDialogHeader>
1488
- <AlertDialogTitle>{confirming.confirm?.title ?? confirming.label}</AlertDialogTitle>
1489
- {confirming.confirm?.message !== undefined && (
1490
- <AlertDialogDescription>{confirming.confirm.message}</AlertDialogDescription>
1491
- )}
1492
- </AlertDialogHeader>
1493
- <AlertDialogFooter>
1494
- <Button variant="ghost" size="sm" onClick={() => setConfirming(null)}>
1495
- Cancelar
1496
- </Button>
1497
- <Button
1498
- size="sm"
1499
- variant={confirming.destructive === true ? 'destructive' : 'default'}
1500
- onClick={() => {
1501
- const a = confirming
1502
- setConfirming(null)
1503
- void runBatch(a)
1504
- }}
1505
- >
1506
- {confirming.label}
1507
- </Button>
1508
- </AlertDialogFooter>
1509
- </AlertDialogContent>
1510
- </AlertDialog>
1511
- )}
1512
- </div>
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(params: URLSearchParams, action: ListActionLike): ActionListState {
1530
- const defaultSort = action.sort?.default?.[0] ?? null
1531
- const sortParam = params.get('sort')
1532
- let sort: SortSpec | null = defaultSort
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 !== '') sort = { field, dir: dir === 'asc' ? 'asc' : 'desc' }
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 !== '') filters[name] = spec.multiple === true ? v.split(',') : 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('cols')
1543
- const limitParam = Number(params.get('limit') ?? '')
1544
- const page = Number(params.get('page') ?? '1')
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('period')
1547
- const range = periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null
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('q') ?? '',
1857
+ q: params.get("q") ?? "",
1550
1858
  sort,
1551
1859
  filters,
1552
- period: range !== null ? 'custom' : periodParam,
1553
- from: range?.[1] ?? params.get('from'),
1554
- to: range?.[2] ?? params.get('to'),
1555
- view: params.get('view'),
1556
- columns: cols !== null && cols !== '' ? cols.split(',') : null,
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(state: ActionListState, action: ListActionLike): string {
1564
- const qs = new URLSearchParams()
1565
- if (state.q.trim() !== '') qs.set('q', state.q.trim())
1566
- const d = action.sort?.default?.[0]
1567
- if (state.sort !== null && !(d !== undefined && state.sort.field === d.field && state.sort.dir === d.dir)) {
1568
- qs.set('sort', `${state.sort.field}:${state.sort.dir}`)
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(',') : String(value))
1889
+ if (isEmptyValue(value)) continue;
1890
+ qs.set(name, Array.isArray(value) ? value.join(",") : String(value));
1573
1891
  }
1574
- if (state.period === 'custom') {
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) qs.set('period', `${state.from}..${state.to}`)
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('period', state.period)
1898
+ qs.set("period", state.period);
1580
1899
  }
1581
- if (state.view !== null) qs.set('view', state.view)
1582
- if (state.columns !== null) qs.set('cols', state.columns.join(','))
1583
- if (state.limit !== null) qs.set('limit', String(state.limit))
1584
- if (state.page > 1) qs.set('page', String(state.page))
1585
- const s = qs.toString()
1586
- return s === '' ? '' : `?${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 };