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