@softize/opus 8.6.6

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 (286) hide show
  1. package/CHANGELOG.md +1616 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.mjs +528 -0
  5. package/bin/lib/check.mjs +307 -0
  6. package/bin/lib/components.mjs +151 -0
  7. package/bin/lib/create.mjs +208 -0
  8. package/bin/lib/db-check-runner.mjs +86 -0
  9. package/bin/lib/db-migrate-runner.mjs +89 -0
  10. package/bin/lib/db-scaffold-runner.mjs +84 -0
  11. package/bin/lib/db.mjs +261 -0
  12. package/bin/lib/docs-include.mjs +48 -0
  13. package/bin/lib/gen-dicts.mjs +134 -0
  14. package/bin/lib/gen-docs.mjs +288 -0
  15. package/bin/lib/gen-manifest.mjs +102 -0
  16. package/bin/lib/gen-openapi.mjs +195 -0
  17. package/bin/lib/gen-runner.mjs +472 -0
  18. package/bin/lib/gen-stubs.mjs +463 -0
  19. package/bin/lib/gen.mjs +311 -0
  20. package/bin/lib/init.mjs +514 -0
  21. package/bin/lib/introspect.mjs +107 -0
  22. package/bin/lib/mcp.mjs +85 -0
  23. package/bin/lib/postinstall.mjs +56 -0
  24. package/docs/chat-event-protocol.md +85 -0
  25. package/docs/code-style.md +16 -0
  26. package/docs/data-layer.md +246 -0
  27. package/docs/ownership-vs-shadcn-lock.md +102 -0
  28. package/docs/protocol.md +2053 -0
  29. package/docs/releasing.md +110 -0
  30. package/docs/shellnav.md +131 -0
  31. package/package.json +338 -0
  32. package/registry/hooks/hooks.json +26 -0
  33. package/registry/hooks/link-memory-on-start.mjs +46 -0
  34. package/registry/hooks/opus-check-on-stop.mjs +114 -0
  35. package/registry/skills/create-action/SKILL.md +49 -0
  36. package/registry/skills/create-action/scaffold.mjs +122 -0
  37. package/registry/templates/app/_gitignore +3 -0
  38. package/registry/templates/app/_npmrc +1 -0
  39. package/registry/templates/app/_opus/_gitignore +5 -0
  40. package/registry/templates/app/_prettierrc.json +6 -0
  41. package/registry/templates/app/index.html +13 -0
  42. package/registry/templates/app/opus.config.ts +16 -0
  43. package/registry/templates/app/package.json +43 -0
  44. package/registry/templates/app/pnpm-workspace.yaml +11 -0
  45. package/registry/templates/app/public/favicon.svg +4 -0
  46. package/registry/templates/app/src/App.tsx +37 -0
  47. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
  48. package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
  49. package/registry/templates/app/src/domains/tasks/index.ts +13 -0
  50. package/registry/templates/app/src/index.css +18 -0
  51. package/registry/templates/app/src/main.tsx +25 -0
  52. package/registry/templates/app/tsconfig.json +20 -0
  53. package/registry/templates/app/vite.config.ts +46 -0
  54. package/registry/templates/monorepo/_gitignore +3 -0
  55. package/registry/templates/monorepo/_npmrc +1 -0
  56. package/registry/templates/monorepo/package.json +9 -0
  57. package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
  58. package/src/ai/ask.ts +64 -0
  59. package/src/ai/drivers/anthropic.ts +309 -0
  60. package/src/ai/index.ts +17 -0
  61. package/src/audit/drivers/console.ts +117 -0
  62. package/src/audit/drivers/pg.ts +172 -0
  63. package/src/audit/index.ts +51 -0
  64. package/src/auth/drivers/better-auth.ts +103 -0
  65. package/src/auth/drivers/jwt.ts +188 -0
  66. package/src/auth/index.ts +9 -0
  67. package/src/client/drivers/fetch.ts +202 -0
  68. package/src/client/index.ts +22 -0
  69. package/src/core/actions.ts +110 -0
  70. package/src/core/audit.ts +239 -0
  71. package/src/core/contracts.ts +137 -0
  72. package/src/core/domain.ts +310 -0
  73. package/src/core/errors.ts +181 -0
  74. package/src/core/index.ts +174 -0
  75. package/src/core/logical-type.ts +31 -0
  76. package/src/core/reactions.ts +81 -0
  77. package/src/core/runtime.ts +1167 -0
  78. package/src/core/schedules.ts +41 -0
  79. package/src/core/types.ts +1356 -0
  80. package/src/data/drivers/kysely.ts +389 -0
  81. package/src/data/index.ts +10 -0
  82. package/src/data/readonly-pool.ts +160 -0
  83. package/src/dsl/eval.ts +136 -0
  84. package/src/dsl/index.ts +29 -0
  85. package/src/dsl/kysely.ts +230 -0
  86. package/src/dsl/loads.ts +123 -0
  87. package/src/dsl/parser.ts +423 -0
  88. package/src/dsl/types.ts +113 -0
  89. package/src/events/drivers/mitt.ts +70 -0
  90. package/src/events/index.ts +9 -0
  91. package/src/log/drivers/pino.ts +57 -0
  92. package/src/log/index.ts +9 -0
  93. package/src/mcp/index.ts +62 -0
  94. package/src/queue/drivers/bullmq.ts +190 -0
  95. package/src/queue/index.ts +9 -0
  96. package/src/scheduler/drivers/node-cron.ts +93 -0
  97. package/src/scheduler/every.ts +45 -0
  98. package/src/scheduler/index.ts +9 -0
  99. package/src/schema/drivers/zod.ts +765 -0
  100. package/src/schema/entity.ts +439 -0
  101. package/src/schema/format/locale.ts +144 -0
  102. package/src/schema/index.ts +65 -0
  103. package/src/schema/openapi.ts +302 -0
  104. package/src/schema/scaffold.ts +160 -0
  105. package/src/server/drivers/fastify.ts +224 -0
  106. package/src/server/drivers/node.ts +386 -0
  107. package/src/server/index.ts +142 -0
  108. package/src/storage/drivers/fs.ts +90 -0
  109. package/src/storage/drivers/s3.ts +117 -0
  110. package/src/storage/index.ts +27 -0
  111. package/src/testing/fake.ts +298 -0
  112. package/src/testing/index.ts +324 -0
  113. package/src/ui/components/patterns/action-form-card.tsx +48 -0
  114. package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
  115. package/src/ui/components/patterns/app-shell.tsx +227 -0
  116. package/src/ui/components/patterns/confirm.tsx +226 -0
  117. package/src/ui/components/patterns/data-state.tsx +75 -0
  118. package/src/ui/components/patterns/form-dialog.tsx +64 -0
  119. package/src/ui/components/patterns/form.tsx +584 -0
  120. package/src/ui/components/patterns/list.tsx +1488 -0
  121. package/src/ui/components/patterns/page.tsx +46 -0
  122. package/src/ui/components/patterns/section-shell.tsx +246 -0
  123. package/src/ui/components/patterns/shell-nav.tsx +150 -0
  124. package/src/ui/components/patterns/sidebar.tsx +89 -0
  125. package/src/ui/components/patterns/split.tsx +93 -0
  126. package/src/ui/components/patterns/trigger.tsx +196 -0
  127. package/src/ui/components/patterns/view.tsx +84 -0
  128. package/src/ui/components/primitives/accordion.tsx +64 -0
  129. package/src/ui/components/primitives/alert-dialog.tsx +190 -0
  130. package/src/ui/components/primitives/alert.tsx +116 -0
  131. package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
  132. package/src/ui/components/primitives/avatar.tsx +107 -0
  133. package/src/ui/components/primitives/badge.tsx +37 -0
  134. package/src/ui/components/primitives/breadcrumb.tsx +109 -0
  135. package/src/ui/components/primitives/button-group.tsx +83 -0
  136. package/src/ui/components/primitives/button.tsx +102 -0
  137. package/src/ui/components/primitives/calendar.tsx +218 -0
  138. package/src/ui/components/primitives/card.tsx +56 -0
  139. package/src/ui/components/primitives/carousel.tsx +239 -0
  140. package/src/ui/components/primitives/chat.tsx +407 -0
  141. package/src/ui/components/primitives/checkbox.tsx +30 -0
  142. package/src/ui/components/primitives/collapsible.tsx +31 -0
  143. package/src/ui/components/primitives/command.tsx +182 -0
  144. package/src/ui/components/primitives/composer.tsx +121 -0
  145. package/src/ui/components/primitives/copyable.tsx +50 -0
  146. package/src/ui/components/primitives/dialog.tsx +147 -0
  147. package/src/ui/components/primitives/drawer.tsx +141 -0
  148. package/src/ui/components/primitives/empty.tsx +104 -0
  149. package/src/ui/components/primitives/field.tsx +246 -0
  150. package/src/ui/components/primitives/icon-picker.tsx +180 -0
  151. package/src/ui/components/primitives/input-group.tsx +168 -0
  152. package/src/ui/components/primitives/input-otp.tsx +75 -0
  153. package/src/ui/components/primitives/input.tsx +72 -0
  154. package/src/ui/components/primitives/item.tsx +193 -0
  155. package/src/ui/components/primitives/kbd.tsx +28 -0
  156. package/src/ui/components/primitives/label.tsx +22 -0
  157. package/src/ui/components/primitives/markdown.tsx +35 -0
  158. package/src/ui/components/primitives/menu.tsx +255 -0
  159. package/src/ui/components/primitives/pagination.tsx +127 -0
  160. package/src/ui/components/primitives/popover.tsx +87 -0
  161. package/src/ui/components/primitives/progress.tsx +29 -0
  162. package/src/ui/components/primitives/radio-group.tsx +43 -0
  163. package/src/ui/components/primitives/resizable.tsx +51 -0
  164. package/src/ui/components/primitives/scroll-area.tsx +56 -0
  165. package/src/ui/components/primitives/select.tsx +479 -0
  166. package/src/ui/components/primitives/separator.tsx +26 -0
  167. package/src/ui/components/primitives/skeleton.tsx +13 -0
  168. package/src/ui/components/primitives/slider.tsx +61 -0
  169. package/src/ui/components/primitives/sonner.tsx +46 -0
  170. package/src/ui/components/primitives/spinner.tsx +29 -0
  171. package/src/ui/components/primitives/switch.tsx +33 -0
  172. package/src/ui/components/primitives/table.tsx +114 -0
  173. package/src/ui/components/primitives/tabs.tsx +104 -0
  174. package/src/ui/components/primitives/textarea.tsx +18 -0
  175. package/src/ui/components/primitives/toggle-group.tsx +81 -0
  176. package/src/ui/components/primitives/toggle.tsx +45 -0
  177. package/src/ui/components/primitives/tooltip.tsx +55 -0
  178. package/src/ui/components/primitives/truncate.tsx +49 -0
  179. package/src/ui/docs/DocBrowser.tsx +90 -0
  180. package/src/ui/docs/changelog.tsx +80 -0
  181. package/src/ui/docs/content/accordion.md +86 -0
  182. package/src/ui/docs/content/action-form-card.md +24 -0
  183. package/src/ui/docs/content/action-form-dialog.md +30 -0
  184. package/src/ui/docs/content/action-form.md +125 -0
  185. package/src/ui/docs/content/action-list-dialog.md +68 -0
  186. package/src/ui/docs/content/action-list.md +194 -0
  187. package/src/ui/docs/content/action-trigger.md +72 -0
  188. package/src/ui/docs/content/action-view.md +47 -0
  189. package/src/ui/docs/content/actions.md +138 -0
  190. package/src/ui/docs/content/ai.md +112 -0
  191. package/src/ui/docs/content/alert-dialog.md +73 -0
  192. package/src/ui/docs/content/alert.md +69 -0
  193. package/src/ui/docs/content/app-shell.md +155 -0
  194. package/src/ui/docs/content/aspect-ratio.md +66 -0
  195. package/src/ui/docs/content/audit.md +84 -0
  196. package/src/ui/docs/content/auth.md +70 -0
  197. package/src/ui/docs/content/avatar.md +94 -0
  198. package/src/ui/docs/content/badge.md +48 -0
  199. package/src/ui/docs/content/breadcrumb.md +87 -0
  200. package/src/ui/docs/content/button-group.md +71 -0
  201. package/src/ui/docs/content/button.md +60 -0
  202. package/src/ui/docs/content/calendar.md +62 -0
  203. package/src/ui/docs/content/card.md +49 -0
  204. package/src/ui/docs/content/carousel.md +85 -0
  205. package/src/ui/docs/content/chat.md +69 -0
  206. package/src/ui/docs/content/checkbox.md +75 -0
  207. package/src/ui/docs/content/cli.md +58 -0
  208. package/src/ui/docs/content/collapsible.md +64 -0
  209. package/src/ui/docs/content/command.md +56 -0
  210. package/src/ui/docs/content/composer.md +50 -0
  211. package/src/ui/docs/content/confirm.md +120 -0
  212. package/src/ui/docs/content/copyable.md +30 -0
  213. package/src/ui/docs/content/customization.md +110 -0
  214. package/src/ui/docs/content/cycle.md +34 -0
  215. package/src/ui/docs/content/data-state.md +47 -0
  216. package/src/ui/docs/content/data.md +99 -0
  217. package/src/ui/docs/content/dialog.md +60 -0
  218. package/src/ui/docs/content/drawer.md +55 -0
  219. package/src/ui/docs/content/empty.md +66 -0
  220. package/src/ui/docs/content/events.md +61 -0
  221. package/src/ui/docs/content/field.md +58 -0
  222. package/src/ui/docs/content/getting-started.md +109 -0
  223. package/src/ui/docs/content/icon-picker.md +51 -0
  224. package/src/ui/docs/content/input-group.md +78 -0
  225. package/src/ui/docs/content/input-otp.md +72 -0
  226. package/src/ui/docs/content/input.md +78 -0
  227. package/src/ui/docs/content/item.md +84 -0
  228. package/src/ui/docs/content/kbd.md +62 -0
  229. package/src/ui/docs/content/label.md +32 -0
  230. package/src/ui/docs/content/log.md +55 -0
  231. package/src/ui/docs/content/markdown.md +41 -0
  232. package/src/ui/docs/content/mcp.md +44 -0
  233. package/src/ui/docs/content/menu.md +114 -0
  234. package/src/ui/docs/content/microcopy.md +83 -0
  235. package/src/ui/docs/content/page.md +34 -0
  236. package/src/ui/docs/content/pagination.md +99 -0
  237. package/src/ui/docs/content/popover.md +49 -0
  238. package/src/ui/docs/content/progress.md +69 -0
  239. package/src/ui/docs/content/queue.md +62 -0
  240. package/src/ui/docs/content/radio-group.md +77 -0
  241. package/src/ui/docs/content/resizable.md +86 -0
  242. package/src/ui/docs/content/router.md +56 -0
  243. package/src/ui/docs/content/runtime.md +77 -0
  244. package/src/ui/docs/content/scheduler.md +66 -0
  245. package/src/ui/docs/content/scroll-area.md +89 -0
  246. package/src/ui/docs/content/section-shell.md +121 -0
  247. package/src/ui/docs/content/select.md +342 -0
  248. package/src/ui/docs/content/separator.md +33 -0
  249. package/src/ui/docs/content/sidebar.md +38 -0
  250. package/src/ui/docs/content/skeleton.md +34 -0
  251. package/src/ui/docs/content/slider.md +64 -0
  252. package/src/ui/docs/content/spinner.md +37 -0
  253. package/src/ui/docs/content/split.md +33 -0
  254. package/src/ui/docs/content/storage.md +69 -0
  255. package/src/ui/docs/content/switch.md +69 -0
  256. package/src/ui/docs/content/table.md +102 -0
  257. package/src/ui/docs/content/tabs.md +94 -0
  258. package/src/ui/docs/content/testing.md +89 -0
  259. package/src/ui/docs/content/textarea.md +30 -0
  260. package/src/ui/docs/content/toast.md +67 -0
  261. package/src/ui/docs/content/toggle-group.md +81 -0
  262. package/src/ui/docs/content/toggle.md +72 -0
  263. package/src/ui/docs/content/tokens.md +171 -0
  264. package/src/ui/docs/content/tooltip.md +50 -0
  265. package/src/ui/docs/content/truncate.md +37 -0
  266. package/src/ui/docs/content/ui.md +40 -0
  267. package/src/ui/docs/content/upgrading.md +48 -0
  268. package/src/ui/docs/doc-client.tsx +214 -0
  269. package/src/ui/docs/doc.tsx +301 -0
  270. package/src/ui/docs/folder.tsx +149 -0
  271. package/src/ui/docs/index.ts +21 -0
  272. package/src/ui/docs/markdown.tsx +130 -0
  273. package/src/ui/docs/md-raw.d.ts +4 -0
  274. package/src/ui/docs/plugin.ts +104 -0
  275. package/src/ui/docs/registry.tsx +424 -0
  276. package/src/ui/docs/standalone.tsx +107 -0
  277. package/src/ui/drivers/react.tsx +627 -0
  278. package/src/ui/index.ts +92 -0
  279. package/src/ui/lib/cn.ts +10 -0
  280. package/src/ui/lib/zod-pt-br.ts +38 -0
  281. package/src/ui/meta.ts +412 -0
  282. package/src/ui/react.tsx +235 -0
  283. package/src/ui/router.ts +96 -0
  284. package/src/ui/theme.css +234 -0
  285. package/src/vite/design.ts +652 -0
  286. package/src/vite/index.ts +8 -0
@@ -0,0 +1,1488 @@
1
+ /**
2
+ * <ActionList action input /> — a listagem padronizada de uma ListAction do Opus.
3
+ *
4
+ * DECLARATIVO PELO CONTRATO (a diagramação da casa): o contrato descreve e a UI deriva —
5
+ * - `columns` (ListColumnSpec) → tabela com tipos (text/number/date/badge), `fit`,
6
+ * `hidden` e headers ordenáveis (`sortable` → `sort: '<key>:<dir>'` no input; o
7
+ * handler implementa o orderBy). Células custom entram por cima via `cells`.
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.
10
+ * - `text` → caixa de busca (convenção: param `q` no input).
11
+ * - `sort.default` → ordenação inicial.
12
+ *
13
+ * Estado da toolbar: interno por padrão; controlado via `state`/`onStateChange` (pra
14
+ * quem embala sincronizar com a URL). O `input` da prop é o ESCOPO BASE (ex.:
15
+ * { workspaceId }) — a toolbar soma por cima e nunca o sobrescreve.
16
+ *
17
+ * Modos de render: tabela derivada (default) · `columns` prop (tabela explícita,
18
+ * legado) · children `(items, refetch) => nó` (layout livre; a toolbar continua).
19
+ * Empty/loading/error padronizados. `data-action` na raiz (selector E2E).
20
+ */
21
+
22
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
+ import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon, ChevronLeft, ChevronRight, Eraser, RefreshCw, Settings2, SlidersHorizontal, Table2, X } from 'lucide-react'
24
+ import type {
25
+ ActionDef,
26
+ FilterSpec,
27
+ Paginated,
28
+ PeriodSpec,
29
+ ListAction,
30
+ ListColumnSpec,
31
+ SortSpec,
32
+ } from '../../../core/index.ts'
33
+ import { useDicts, useListAction, useLookupAction, type DictLike } from '../../drivers/react.tsx'
34
+ import { cn } from '../../lib/cn.ts'
35
+ import { Badge } from '../primitives/badge.tsx'
36
+ import { Button } from '../primitives/button.tsx'
37
+ import { Calendar } from '../primitives/calendar.tsx'
38
+ import { Checkbox } from '../primitives/checkbox.tsx'
39
+ import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover.tsx'
40
+ import {
41
+ Dialog,
42
+ DialogBody,
43
+ DialogContent,
44
+ DialogFooter,
45
+ DialogHeader,
46
+ DialogTitle,
47
+ } from '../primitives/dialog.tsx'
48
+ import {
49
+ AlertDialog,
50
+ AlertDialogContent,
51
+ AlertDialogDescription,
52
+ AlertDialogFooter,
53
+ AlertDialogHeader,
54
+ AlertDialogTitle,
55
+ } from '../primitives/alert-dialog.tsx'
56
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../primitives/input-group.tsx'
57
+ import { Separator } from '../primitives/separator.tsx'
58
+ import { Label } from '../primitives/label.tsx'
59
+ import { Search } from 'lucide-react'
60
+ import { Select, type SelectOption } from '../primitives/select.tsx'
61
+ import { Skeleton } from '../primitives/skeleton.tsx'
62
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table.tsx'
63
+ import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
64
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
65
+
66
+ // =============================================================================
67
+ // Helpers
68
+ // =============================================================================
69
+
70
+ /** I18nRef ({ key, default }) | string → texto. */
71
+ function text(m: unknown, fallback = ''): string {
72
+ if (typeof m === 'string') return m
73
+ if (m !== null && typeof m === 'object' && 'default' in m) {
74
+ const d = (m as { default: unknown }).default
75
+ if (typeof d === 'string') return d
76
+ }
77
+ return fallback
78
+ }
79
+
80
+ function isEmptyValue(v: unknown): boolean {
81
+ return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
82
+ }
83
+
84
+ // =============================================================================
85
+ // Tipos públicos
86
+ // =============================================================================
87
+
88
+ export interface ActionListColumn<TItem> {
89
+ /** Identifica a coluna (usado como key React). */
90
+ key: string
91
+ /** Cabeçalho da coluna. */
92
+ header: string
93
+ /** Renderiza o conteúdo da célula a partir do item. */
94
+ cell: (item: TItem) => ReactNode
95
+ /** Largura opcional (CSS class). */
96
+ className?: string
97
+ /** Header clicável (asc ↔ desc) — escreve `sort` no input. */
98
+ sortable?: boolean
99
+ }
100
+
101
+ /** Estado da toolbar (busca + sort + filtros + período + view + colunas visíveis).
102
+ * Controlável de fora pra URL sync — ver listParamsToState/listStateToParams. */
103
+ export interface ActionListState {
104
+ q: string
105
+ sort: SortSpec | null
106
+ filters: Record<string, unknown>
107
+ /** Período ativo: o value de um preset do contrato, ou 'custom'. null = o DEFAULT do
108
+ * contrato (período é recorte obrigatório — não existe "sem período" quando o
109
+ * contrato declara `periods`). Preset fica RELATIVO na URL; materializa no fetch. */
110
+ period: string | null
111
+ /** Range do período 'custom' (YYYY-MM-DD). */
112
+ from: string | null
113
+ to: string | null
114
+ /** View ativa ('table' ou uma chave de `views`). null = a default. */
115
+ view: string | null
116
+ /** Colunas visíveis (config de exibição). null = o default do contrato (não-hidden). */
117
+ columns: string[] | null
118
+ /** Itens por página (config de exibição). null = o `pageSize` do caller. */
119
+ limit: number | null
120
+ /** Página atual (1-based). Mudar filtro/busca/sort/período volta pra 1. */
121
+ page: number
122
+ }
123
+
124
+ /** Um estado vazio/default — útil pra montar o inicial em quem controla. */
125
+ export function emptyListState(): ActionListState {
126
+ return { q: '', sort: null, filters: {}, period: null, from: null, to: null, view: null, columns: null, limit: null, page: 1 }
127
+ }
128
+
129
+ /** Presets de período que o pattern sabe COMPUTAR (o contrato escolhe quais oferecer
130
+ * via `periods`; o valor materializa em `from`/`to` — YYYY-MM-DD — no fetch). */
131
+ export function presetRange(preset: string, now = new Date()): { from: string; to: string } | null {
132
+ const iso = (d: Date): string =>
133
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
134
+ const shift = (days: number): Date => new Date(now.getFullYear(), now.getMonth(), now.getDate() + days)
135
+ switch (preset) {
136
+ case 'today':
137
+ return { from: iso(shift(0)), to: iso(shift(0)) }
138
+ case 'yesterday':
139
+ return { from: iso(shift(-1)), to: iso(shift(-1)) }
140
+ case 'last7':
141
+ return { from: iso(shift(-6)), to: iso(shift(0)) }
142
+ case 'last30':
143
+ return { from: iso(shift(-29)), to: iso(shift(0)) }
144
+ case 'thisMonth':
145
+ return { from: iso(new Date(now.getFullYear(), now.getMonth(), 1)), to: iso(shift(0)) }
146
+ case 'lastMonth':
147
+ return {
148
+ from: iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)),
149
+ to: iso(new Date(now.getFullYear(), now.getMonth(), 0)),
150
+ }
151
+ default:
152
+ return null
153
+ }
154
+ }
155
+
156
+ /** Estrutural de propósito (como o useListAction): aceita o CONTRATO compartilhado
157
+ * (sem handler) e a action completa — só o que a UI deriva importa aqui. */
158
+ export interface ListActionLike {
159
+ name: string
160
+ kind: 'list'
161
+ columns?: ListColumnSpec[] | undefined
162
+ filters?: Record<string, FilterSpec> | undefined
163
+ sort?: { fields: string[]; default?: SortSpec[] } | undefined
164
+ text?: { fields: string[]; placeholder?: unknown } | undefined
165
+ periods?: PeriodSpec[] | undefined
166
+ }
167
+
168
+ /** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
169
+ * apresentação é de quem chama; o pattern dá o segment, o estado e os dados. */
170
+ export interface ActionListView<TItem> {
171
+ label: string
172
+ /** Ícone do segment (icon-only, com o label em title/aria). Sem ele, o label vira texto. */
173
+ icon?: ReactNode
174
+ render: (items: TItem[], refetch: () => Promise<void>) => ReactNode
175
+ }
176
+
177
+ /** Ação em lote sobre os selecionados. `can` é a FONTE ÚNICA de elegibilidade: governa
178
+ * o checkbox da linha, a coluna de seleção inteira e o que chega no `run` (só os
179
+ * elegíveis). Sem `can` = todo item é elegível. */
180
+ export interface ActionListBatchAction<TItem> {
181
+ label: string
182
+ /** Elegibilidade por item. */
183
+ can?: (item: TItem) => boolean
184
+ /** Roda sobre os selecionados ELEGÍVEIS; ao resolver, a seleção limpa e a lista refaz. */
185
+ run: (items: TItem[]) => Promise<void> | void
186
+ /** Confirmação antes de rodar. */
187
+ confirm?: { title: string; message?: string }
188
+ destructive?: boolean
189
+ }
190
+
191
+ export interface ActionListProps<TInput, TItem> {
192
+ action: ListActionLike
193
+ /** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
194
+ input: TInput
195
+ /** Tabela explícita — sobrepõe as colunas do contrato. */
196
+ columns?: ActionListColumn<TItem>[]
197
+ /** Células custom POR CIMA das colunas do contrato (chave = column.key). */
198
+ cells?: Record<string, (item: TItem) => ReactNode>
199
+ /** Opções de runtime pros filtros select/lookup (chave = nome do filtro). */
200
+ filterOptions?: Record<string, SelectOption[]>
201
+ /** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
202
+ * tabela ('table', quando há columns) e estas. Ex.: { board: { label, render } }. */
203
+ views?: Record<string, ActionListView<TItem>>
204
+ /** Ações em lote (multi-seleção com `can`) — liga a coluna de checkbox na tabela. */
205
+ batch?: ActionListBatchAction<TItem>[]
206
+ /** Identidade da linha pra seleção. Default: `item.id`. */
207
+ rowId?: (item: TItem) => string
208
+ /** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
209
+ * o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
210
+ pageSize?: number
211
+ /** Mensagem de empty state. Default: "Nenhum resultado." */
212
+ emptyMessage?: string
213
+ /** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
214
+ * como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
215
+ empty?: (items: TItem[]) => boolean
216
+ /** Carga EXTRA agregada à do fetch (ex.: a query irmã que os children precisam). */
217
+ loading?: boolean
218
+ /** Click handler por linha (ex: navegar pro detalhe) — só na tabela. */
219
+ onRowClick?: (item: TItem) => void
220
+ /** Ações POR LINHA (coluna final, alinhada à direita) — apresentação de quem chama,
221
+ * como `cells`. Cliques ali não disparam o onRowClick. Só na tabela. */
222
+ rowActions?: (item: TItem) => ReactNode
223
+ /** Modo COMPOSIÇÃO: layout livre ÚNICO (sem segment); toolbar e estados seguem. */
224
+ children?: (items: TItem[], refetch: () => Promise<void>) => ReactNode
225
+ /** Estado da toolbar CONTROLADO (URL sync de quem embala). Default: interno. */
226
+ state?: ActionListState
227
+ onStateChange?: (next: ActionListState) => void
228
+ }
229
+
230
+ // =============================================================================
231
+ // Filtro (leaf) — um campo da toolbar/modal, derivado do FilterSpec
232
+ // =============================================================================
233
+
234
+ function optionsFor(name: string, spec: FilterSpec, runtime?: Record<string, SelectOption[]>): SelectOption[] {
235
+ const fromRuntime = runtime?.[name]
236
+ if (fromRuntime !== undefined) return fromRuntime
237
+ if (spec.options?.kind === 'static') {
238
+ return spec.options.items.map((i) => ({ value: i.value, label: text(i.label, i.value) }))
239
+ }
240
+ return []
241
+ }
242
+
243
+ /** Opções de `options: { kind: 'dictionary', ref }` via dicts do provider. */
244
+ function dictOptionsFor(spec: FilterSpec, dicts: Record<string, DictLike>): SelectOption[] {
245
+ if (spec.options?.kind !== 'dictionary') return []
246
+ const dict = dicts[spec.options.ref]
247
+ if (dict === undefined) return []
248
+ return dict.options().map((o) => ({ value: o.value, label: o.label }))
249
+ }
250
+
251
+ /** Opções vindas de uma action de lookup (`options.kind: 'lookup'`): o Select chama
252
+ * `onSearch` (debounced) e a action `source` devolve itens `{ value, label }` — a
253
+ * convenção do lookup declarativo. Os `depends` entram no input da busca. */
254
+ function useLookupOptions(
255
+ spec: FilterSpec,
256
+ allValues: Record<string, unknown>,
257
+ ): { options: SelectOption[]; loading: boolean; onSearch?: (q: string) => void } {
258
+ const lookup = spec.options?.kind === 'lookup' ? spec.options : undefined
259
+ const search = useLookupAction<Record<string, unknown>, SelectOption>(
260
+ { name: lookup?.source ?? 'noop', kind: 'list' } as unknown as ActionDef,
261
+ )
262
+ if (lookup === undefined) return { options: [], loading: false }
263
+ const depInput: Record<string, unknown> = {}
264
+ for (const d of lookup.depends ?? []) {
265
+ if (!isEmptyValue(allValues[d])) depInput[d] = allValues[d]
266
+ }
267
+ return {
268
+ options: (search.items ?? []).map((i) => ({ value: i.value, label: i.label })),
269
+ loading: search.isLoading,
270
+ onSearch: (q: string) => {
271
+ void search.run({ ...(q !== '' ? { q } : {}), ...depInput })
272
+ },
273
+ }
274
+ }
275
+
276
+ function FilterField({
277
+ name,
278
+ spec,
279
+ value,
280
+ onChange,
281
+ options,
282
+ allValues,
283
+ className,
284
+ }: {
285
+ name: string
286
+ spec: FilterSpec
287
+ value: unknown
288
+ onChange: (v: unknown) => void
289
+ options: SelectOption[]
290
+ /** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
291
+ allValues: Record<string, unknown>
292
+ /** Largura do contexto: a toolbar usa os defaults compactos; o modal manda w-full. */
293
+ className?: string
294
+ }): React.ReactElement {
295
+ const label = text(spec.label, name)
296
+ const placeholder = text(spec.placeholder, label)
297
+ // Dependência não satisfeita = controle desabilitado (o valor cascateia fora no pai).
298
+ const enabled = (spec.depends ?? []).every((d) => !isEmptyValue(allValues[d]))
299
+ const lookup = useLookupOptions(spec, allValues)
300
+ const dicts = useDicts()
301
+ // Precedência: runtime/estáticas (prop `options`) > dictionary do provider;
302
+ // sem nenhuma, as do lookup (server-side).
303
+ const base = options.length > 0 ? options : dictOptionsFor(spec, dicts)
304
+ const effOptions = base.length > 0 ? base : lookup.options
305
+
306
+ if (spec.multiple === true) {
307
+ return (
308
+ <Select
309
+ multiple
310
+ searchable
311
+ clearable
312
+ id={`filter-${name}`}
313
+ value={(value as string[] | undefined) ?? []}
314
+ onChange={(v) => onChange(v)}
315
+ options={effOptions}
316
+ placeholder={placeholder}
317
+ className={cn('min-w-44', className)}
318
+ disabled={!enabled}
319
+ {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
320
+ />
321
+ )
322
+ }
323
+ if (spec.type === 'lookup') {
324
+ // Lookup é typeahead por natureza: Select searchable single (server-side quando a origem
325
+ // é uma action; client quando as opções vieram estáticas/de runtime).
326
+ return (
327
+ <Select
328
+ searchable
329
+ clearable
330
+ id={`filter-${name}`}
331
+ value={(value as string | undefined) ?? ''}
332
+ onChange={(v) => onChange(v)}
333
+ options={effOptions}
334
+ placeholder={placeholder}
335
+ className={cn('min-w-44', className)}
336
+ disabled={!enabled}
337
+ {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
338
+ />
339
+ )
340
+ }
341
+ if (spec.type === 'select') {
342
+ // "Todos" é o valor-vazio EXPLÍCITO (limpa o filtro por dentro, sem chip); o
343
+ // `clearable` dá o mesmo destino num clique, sem abrir a lista.
344
+ const ALL = '__all__'
345
+ return (
346
+ <Select
347
+ clearable
348
+ id={`filter-${name}`}
349
+ value={((value as string | undefined) ?? '') === '' ? ALL : (value as string)}
350
+ onChange={(v) => onChange(v === ALL ? '' : v)}
351
+ options={[{ value: ALL, label: text(spec.placeholder, 'Todos') }, ...base]}
352
+ disabled={!enabled}
353
+ className={cn('min-w-36', className)}
354
+ />
355
+ )
356
+ }
357
+ // text · number · date: aplica no blur/Enter (texto) ou no change (date).
358
+ const type = spec.type === 'number' ? 'number' : spec.type === 'date' ? 'date' : 'text'
359
+ return (
360
+ <ClearableInput
361
+ id={`filter-${name}`}
362
+ type={type}
363
+ applied={(value as string | undefined) ?? ''}
364
+ placeholder={placeholder}
365
+ className={cn('w-40', className)}
366
+ disabled={!enabled}
367
+ onApply={(v) => onChange(v)}
368
+ />
369
+ )
370
+ }
371
+
372
+ /** Input com limpar (a receita clearable dos textos): Input/InputGroup são locked, o X
373
+ * entra por composição — addon à direita, visível só com texto. Uncontrolled (aplica
374
+ * no blur/Enter, como a busca); o X zera o campo E aplica o vazio. */
375
+ function ClearableInput({
376
+ id,
377
+ type,
378
+ applied,
379
+ placeholder,
380
+ className,
381
+ leading,
382
+ disabled,
383
+ onApply,
384
+ }: {
385
+ id?: string
386
+ type?: string
387
+ applied: string
388
+ placeholder?: string
389
+ className?: string
390
+ /** Addon à esquerda (ex.: a lupa da busca). */
391
+ leading?: ReactNode
392
+ disabled?: boolean
393
+ onApply: (v: string) => void
394
+ }): React.ReactElement {
395
+ const inputRef = useRef<HTMLInputElement | null>(null)
396
+ const [hasText, setHasText] = useState(applied !== '')
397
+ // Valor aplicado mudou por fora (chip removido, URL) → re-sincroniza o campo.
398
+ useEffect(() => {
399
+ if (inputRef.current !== null && inputRef.current.value !== applied) inputRef.current.value = applied
400
+ setHasText(applied !== '')
401
+ }, [applied])
402
+ return (
403
+ <InputGroup className={className}>
404
+ {leading !== undefined && <InputGroupAddon>{leading}</InputGroupAddon>}
405
+ <InputGroupInput
406
+ ref={inputRef}
407
+ id={id}
408
+ type={type}
409
+ className="h-full"
410
+ disabled={disabled}
411
+ defaultValue={applied}
412
+ placeholder={placeholder}
413
+ onChange={(e) => setHasText(e.target.value !== '')}
414
+ onBlur={(e) => {
415
+ if (e.target.value !== applied) onApply(e.target.value)
416
+ }}
417
+ onKeyDown={(e) => {
418
+ if (e.key === 'Enter') {
419
+ e.preventDefault()
420
+ onApply((e.target as HTMLInputElement).value)
421
+ }
422
+ }}
423
+ />
424
+ {hasText && (
425
+ <InputGroupAddon align="inline-end">
426
+ <InputGroupButton
427
+ aria-label="Limpar"
428
+ onClick={() => {
429
+ if (inputRef.current !== null) inputRef.current.value = ''
430
+ setHasText(false)
431
+ onApply('')
432
+ }}
433
+ >
434
+ <X className="size-3.5" />
435
+ </InputGroupButton>
436
+ </InputGroupAddon>
437
+ )}
438
+ </InputGroup>
439
+ )
440
+ }
441
+
442
+ /** Janela de páginas do pager: vizinhas da atual + pontas, com reticências nos vãos. */
443
+ function pageWindow(current: number, pages: number): Array<number | '…'> {
444
+ if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1)
445
+ const wanted = new Set<number>([1, 2, 3, current - 1, current, current + 1, pages])
446
+ const nums = Array.from(wanted)
447
+ .filter((n) => n >= 1 && n <= pages)
448
+ .sort((a, b) => a - b)
449
+ const out: Array<number | '…'> = []
450
+ for (const [i, n] of nums.entries()) {
451
+ if (i > 0 && n - (nums[i - 1] as number) > 1) out.push('…')
452
+ out.push(n)
453
+ }
454
+ return out
455
+ }
456
+
457
+ /** Label pequena ACIMA do controle — o estilo da toolbar (Período, Status, …). */
458
+ function Labeled({
459
+ label,
460
+ children,
461
+ slot,
462
+ }: {
463
+ label: string
464
+ children: ReactNode
465
+ /** data-slot do wrapper (ex.: marca os filtros inline pro overflow responsivo). */
466
+ slot?: string
467
+ }): React.ReactElement {
468
+ return (
469
+ <div data-slot={slot} className="flex shrink-0 flex-col gap-1">
470
+ <span className="px-0.5 text-xs font-medium text-muted-foreground">{label}</span>
471
+ {children}
472
+ </div>
473
+ )
474
+ }
475
+
476
+ // =============================================================================
477
+ // Período — o campo dinâmico: presets num popover; "Personalizado" abre o calendário
478
+ // de range ali mesmo. Fica INLINE na toolbar (não é filtro avançado).
479
+ // =============================================================================
480
+
481
+ function fmtDay(isoDay: string): string {
482
+ const [y, m, d] = isoDay.split('-')
483
+ return `${d}/${m}/${y?.slice(2)}`
484
+ }
485
+
486
+ function PeriodControl({
487
+ periods,
488
+ period,
489
+ from,
490
+ to,
491
+ onChange,
492
+ }: {
493
+ periods: PeriodSpec[]
494
+ period: string | null
495
+ from: string | null
496
+ to: string | null
497
+ onChange: (next: { period: string | null; from: string | null; to: string | null }) => void
498
+ }): React.ReactElement {
499
+ const [open, setOpen] = useState(false)
500
+ const [showCalendar, setShowCalendar] = useState(period === 'custom')
501
+ // Seleção em duas pontas na MÃO (onDayClick), fora da lógica de range do
502
+ // react-day-picker: com o range vigente pré-selecionado, o RDP estenderia a
503
+ // seleção antiga em vez de começar outra — e ele devolve from E to já no
504
+ // primeiro clique. Sem draft = mostra o range vigente; o primeiro clique
505
+ // abre a ponta inicial; o segundo completa, aplica e fecha.
506
+ const [draft, setDraft] = useState<{ from: Date | undefined; to?: Date | undefined } | undefined>(undefined)
507
+
508
+ const label =
509
+ period === 'custom'
510
+ ? from !== null && to !== null
511
+ ? `${fmtDay(from)} – ${fmtDay(to)}`
512
+ : 'Personalizado'
513
+ : text(periods.find((p) => p.value === period)?.label, period ?? '')
514
+
515
+ const parseDay = (v: string | null): Date | undefined => {
516
+ if (v === null) return undefined
517
+ const [y, m, d] = v.split('-').map(Number)
518
+ return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1)
519
+ }
520
+ const toDay = (d: Date): string =>
521
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
522
+
523
+ return (
524
+ <Popover
525
+ open={open}
526
+ onOpenChange={(o) => {
527
+ setOpen(o)
528
+ if (o) {
529
+ setShowCalendar(period === 'custom')
530
+ setDraft(undefined)
531
+ }
532
+ }}
533
+ >
534
+ <PopoverTrigger asChild>
535
+ <Button variant="outline" data-slot="action-list-period">
536
+ <CalendarIcon className="h-3.5 w-3.5" />
537
+ {label}
538
+ </Button>
539
+ </PopoverTrigger>
540
+ <PopoverContent align="start" className="w-auto p-0">
541
+ <div className="flex">
542
+ <div className={cn('flex min-w-36 flex-col gap-0.5 p-2', showCalendar && 'border-r border-border')}>
543
+ {periods.map((p) => (
544
+ <Button
545
+ key={p.value}
546
+ size="sm"
547
+ variant={period === p.value ? 'secondary' : 'ghost'}
548
+ className="justify-start"
549
+ onClick={() => {
550
+ onChange({ period: p.value, from: null, to: null })
551
+ setOpen(false)
552
+ }}
553
+ >
554
+ {text(p.label, p.value)}
555
+ </Button>
556
+ ))}
557
+ <Button
558
+ size="sm"
559
+ variant={period === 'custom' ? 'secondary' : 'ghost'}
560
+ className="justify-start"
561
+ onClick={() => {
562
+ setShowCalendar(true)
563
+ setDraft(undefined)
564
+ }}
565
+ >
566
+ Personalizado
567
+ </Button>
568
+ </div>
569
+ {showCalendar && (
570
+ <Calendar
571
+ mode="range"
572
+ numberOfMonths={1}
573
+ defaultMonth={parseDay(from)}
574
+ selected={draft ?? { from: parseDay(from), to: parseDay(to) }}
575
+ // No-op de propósito: com onSelect o RDP fica CONTROLADO (renderiza o
576
+ // nosso `selected`); sem ele, mantém seleção interna e mostra o range
577
+ // antigo estendido no meio da escolha.
578
+ onSelect={() => undefined}
579
+ onDayClick={(day) => {
580
+ if (draft?.from !== undefined && draft.to === undefined) {
581
+ // Segunda ponta (qualquer ordem): completa, aplica e fecha.
582
+ const [a, b] = day < draft.from ? [day, draft.from] : [draft.from, day]
583
+ onChange({ period: 'custom', from: toDay(a), to: toDay(b) })
584
+ setOpen(false)
585
+ } else {
586
+ setDraft({ from: day, to: undefined })
587
+ }
588
+ }}
589
+ />
590
+ )}
591
+ </div>
592
+ </PopoverContent>
593
+ </Popover>
594
+ )
595
+ }
596
+
597
+ // =============================================================================
598
+ // Modal de filtros avançados — draft local; Aplicar/Limpar
599
+ // =============================================================================
600
+
601
+ function AdvancedFiltersDialog({
602
+ open,
603
+ onOpenChange,
604
+ specs,
605
+ values,
606
+ onApply,
607
+ filterOptions,
608
+ cascade,
609
+ }: {
610
+ open: boolean
611
+ onOpenChange: (o: boolean) => void
612
+ specs: Array<[string, FilterSpec]>
613
+ values: Record<string, unknown>
614
+ onApply: (next: Record<string, unknown>) => void
615
+ filterOptions?: Record<string, SelectOption[]> | undefined
616
+ /** A cascata do `depends` do pai — mudar um campo limpa os dependentes no draft. */
617
+ cascade: (changed: string, next: Record<string, unknown>) => Record<string, unknown>
618
+ }): React.ReactElement {
619
+ const [draft, setDraft] = useState<Record<string, unknown>>(values)
620
+ // Reabriu → re-sincroniza o draft com o aplicado.
621
+ useEffect(() => {
622
+ if (open) setDraft(values)
623
+ }, [open, values])
624
+
625
+ return (
626
+ <Dialog open={open} onOpenChange={onOpenChange}>
627
+ {/* Estreito de propósito (form de filtros, campos em coluna full width); sem
628
+ subtítulo — o título basta (aria-describedby explícito cala o aviso do Radix). */}
629
+ <DialogContent className="sm:max-w-sm" aria-describedby={undefined}>
630
+ <DialogHeader>
631
+ <DialogTitle>Filtros</DialogTitle>
632
+ </DialogHeader>
633
+ <DialogBody className="space-y-4">
634
+ {specs.map(([name, spec]) => (
635
+ <div key={name} className="space-y-2">
636
+ <Label htmlFor={`filter-${name}`}>{text(spec.label, name)}</Label>
637
+ <FilterField
638
+ name={name}
639
+ spec={spec}
640
+ value={draft[name]}
641
+ allValues={draft}
642
+ className="w-full"
643
+ onChange={(v) => setDraft((d) => cascade(name, { ...d, [name]: v }))}
644
+ options={optionsFor(name, spec, filterOptions)}
645
+ />
646
+ </div>
647
+ ))}
648
+ </DialogBody>
649
+ <DialogFooter>
650
+ <Button
651
+ variant="outline"
652
+ size="sm"
653
+ onClick={() => {
654
+ const cleared = { ...draft }
655
+ for (const [name] of specs) delete cleared[name]
656
+ onApply(cleared)
657
+ onOpenChange(false)
658
+ }}
659
+ >
660
+ Limpar
661
+ </Button>
662
+ <Button
663
+ size="sm"
664
+ onClick={() => {
665
+ onApply(draft)
666
+ onOpenChange(false)
667
+ }}
668
+ >
669
+ Aplicar
670
+ </Button>
671
+ </DialogFooter>
672
+ </DialogContent>
673
+ </Dialog>
674
+ )
675
+ }
676
+
677
+ // =============================================================================
678
+ // ActionList
679
+ // =============================================================================
680
+
681
+ export function ActionList<TInput, TItem>({
682
+ action,
683
+ input,
684
+ columns,
685
+ cells,
686
+ filterOptions,
687
+ views,
688
+ batch,
689
+ rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ''),
690
+ pageSize = 50,
691
+ emptyMessage = 'Nenhum resultado.',
692
+ empty,
693
+ loading: extraLoading,
694
+ onRowClick,
695
+ rowActions,
696
+ children,
697
+ state: controlledState,
698
+ onStateChange,
699
+ }: ActionListProps<TInput, TItem>) {
700
+ // Query-backed (useListAction): busca declarativa por [name, input] E refetch
701
+ // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
702
+ // O input efetivo é computado abaixo; o hook re-busca quando ele muda.
703
+ const hasTable = columns !== undefined || action.columns !== undefined
704
+ if (children === undefined && !hasTable && views === undefined) {
705
+ throw new Error('ActionList precisa de children (composição), columns (prop/contrato) ou views.')
706
+ }
707
+
708
+ // — Estado da toolbar (interno ou controlado) —
709
+ const defaultSort = action.sort?.default?.[0] ?? null
710
+ const [internal, setInternal] = useState<ActionListState>({ ...emptyListState(), sort: defaultSort })
711
+ // Dicts do provider — labels de filtro dictionary nos CHIPS (o FilterField resolve os seus).
712
+ const dicts = useDicts()
713
+ // Normaliza estado externo montado à mão (chaves novas ausentes = null/default).
714
+ const raw = controlledState ?? internal
715
+ const state: ActionListState = {
716
+ ...raw,
717
+ period: raw.period ?? null,
718
+ from: raw.from ?? null,
719
+ to: raw.to ?? null,
720
+ view: raw.view ?? null,
721
+ columns: raw.columns ?? null,
722
+ limit: raw.limit ?? null,
723
+ page: raw.page ?? 1,
724
+ }
725
+ const setState = (next: ActionListState): void => {
726
+ if (onStateChange !== undefined) onStateChange(next)
727
+ if (controlledState === undefined) setInternal(next)
728
+ }
729
+ // Mudou o RECORTE (filtro/busca/sort/período), a página volta pra 1.
730
+ const setStateResetPage = (next: ActionListState): void => setState({ ...next, page: 1 })
731
+
732
+ const filterSpecs = Object.entries(action.filters ?? {})
733
+ // Mudou um filtro → limpa (em cascata) todo filtro que declara `depends` nele:
734
+ // o recorte dependente perde o sentido quando o pai muda.
735
+ const cascadeClear = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
736
+ for (const [n, sp] of filterSpecs) {
737
+ if (sp.depends?.includes(changed) === true && !isEmptyValue(next[n])) {
738
+ delete next[n]
739
+ cascadeClear(n, next)
740
+ }
741
+ }
742
+ return next
743
+ }
744
+ const inlineSpecs = filterSpecs.filter(([, s]) => s.advanced !== true)
745
+ const hasSearch = (action.text?.fields.length ?? 0) > 0
746
+ const [advancedOpen, setAdvancedOpen] = useState(false)
747
+
748
+ // — Overflow responsivo: filtro inline que NÃO CABE na linha vai pro modal (com os
749
+ // advanced). Medição real: esconde do fim pro começo até a linha parar de
750
+ // transbordar (useLayoutEffect = antes do paint, sem piscar) e re-mede no resize.
751
+ const toolbarRowRef = useRef<HTMLDivElement | null>(null)
752
+ const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
753
+ useLayoutEffect(() => {
754
+ const row = toolbarRowRef.current
755
+ if (row === null) return
756
+ // Transborda de dois jeitos: a linha estoura (scrollWidth) OU um filho flexível
757
+ // (o grupo da direita, min-w-0) encolhe abaixo do conteúdo e vaza por cima dos
758
+ // vizinhos — esse segundo caso não mexe no scrollWidth da linha.
759
+ const overflowing = (): boolean =>
760
+ row.scrollWidth > row.clientWidth + 1 ||
761
+ Array.from(row.children).some((c) => c.scrollWidth > c.clientWidth + 1)
762
+ const compute = (): void => {
763
+ // Medição sempre em linha única; o wrap é o último recurso (re-avaliado abaixo).
764
+ row.classList.remove('flex-wrap')
765
+ const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-list-inline-filter"]'))
766
+ for (const n of nodes) n.style.removeProperty('display')
767
+ let count = nodes.length
768
+ while (count > 0 && overflowing()) {
769
+ count -= 1
770
+ const node = nodes[count]
771
+ if (node !== undefined) node.style.display = 'none'
772
+ }
773
+ // Esgotou (zero filtros na linha) e AINDA não cabe → quebra a linha em vez de
774
+ // recortar controle (busca no mínimo + segment + botões > largura disponível).
775
+ if (count === 0 && overflowing()) row.classList.add('flex-wrap')
776
+ setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
777
+ }
778
+ compute()
779
+ const ro = new ResizeObserver(compute)
780
+ ro.observe(row)
781
+ return () => ro.disconnect()
782
+ // inlineFit na dep: o setInlineFit pode fazer o botão "Filtros" aparecer e
783
+ // mudar a medida — o compute é idempotente, então re-rodar converge (sem loop).
784
+ }, [inlineSpecs.length, state.filters, inlineFit])
785
+ const overflowSpecs = Number.isFinite(inlineFit) ? inlineSpecs.slice(inlineFit) : []
786
+ // O modal recebe os advanced + os inline que transbordaram (ordem do contrato).
787
+ const modalSpecs = filterSpecs.filter(
788
+ ([name, s]) => s.advanced === true || overflowSpecs.some(([n]) => n === name),
789
+ )
790
+ const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
791
+
792
+ // — Views: 'table' (quando há colunas) + as nomeadas. O segment só existe com 2+. —
793
+ const viewEntries: Array<[string, string, ReactNode?]> = [
794
+ ...(hasTable ? ([['table', 'Tabela', <Table2 key="t" />]] as Array<[string, string, ReactNode?]>) : []),
795
+ ...Object.entries(views ?? {}).map(([key, v]) => [key, v.label, v.icon] as [string, string, ReactNode?]),
796
+ ]
797
+ const activeView = state.view ?? viewEntries[0]?.[0] ?? 'table'
798
+ const hasSegment = children === undefined && viewEntries.length > 1
799
+ const periods = action.periods ?? []
800
+ // Período é recorte obrigatório: o default é o preset marcado (ou o primeiro).
801
+ const defaultPeriod = periods.find((p) => p.default === true)?.value ?? periods[0]?.value ?? null
802
+ const activePeriod = state.period ?? defaultPeriod
803
+ const hasToolbar = hasSearch || filterSpecs.length > 0 || periods.length > 0 || hasSegment || hasTable
804
+
805
+ // — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
806
+ const effectiveInput = useMemo(() => {
807
+ const filled: Record<string, unknown> = {}
808
+ for (const [name, v] of Object.entries(state.filters)) {
809
+ if (!isEmptyValue(v)) filled[name] = v
810
+ }
811
+ // Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
812
+ // range. state.period null = o default do contrato (sempre aplicado).
813
+ const effectivePeriod = state.period ?? (action.periods?.find((p) => p.default === true)?.value ?? action.periods?.[0]?.value ?? null)
814
+ let range: { from: string; to: string } | null = null
815
+ if (effectivePeriod === 'custom' && state.from !== null && state.to !== null) {
816
+ range = { from: state.from, to: state.to }
817
+ } else if (effectivePeriod !== null && effectivePeriod !== 'custom') {
818
+ range = presetRange(effectivePeriod)
819
+ }
820
+ return {
821
+ ...(input as Record<string, unknown>),
822
+ ...filled,
823
+ ...(range !== null ? range : {}),
824
+ ...(state.q.trim() !== '' ? { q: state.q.trim() } : {}),
825
+ ...(state.sort !== null ? { sort: `${state.sort.field}:${state.sort.dir}` } : {}),
826
+ // Paginação server-driven: o handler implementa OFFSET e devolve `total`.
827
+ limit: state.limit ?? pageSize,
828
+ page: state.page,
829
+ } as TInput
830
+ }, [input, state, pageSize])
831
+
832
+ const inputKey = JSON.stringify(effectiveInput)
833
+ const { items, total, isLoading, isFetching, isError, error, refetch } = useListAction<
834
+ TItem,
835
+ Record<string, unknown>
836
+ >(action as unknown as { name: string; kind: 'list' }, effectiveInput as Record<string, unknown>)
837
+ // — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
838
+ // picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
839
+ const visibleKeys = state.columns
840
+ const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> = useMemo(() => {
841
+ if (columns !== undefined) return columns
842
+ return (action.columns ?? [])
843
+ .filter((c) => (visibleKeys !== null ? visibleKeys.includes(c.key) : c.hidden !== true))
844
+ .map((c) => ({
845
+ key: c.key,
846
+ header: text(c.label, c.key),
847
+ sortable: c.sortable === true,
848
+ spec: c,
849
+ className: cn(
850
+ c.fit === true && 'w-px whitespace-nowrap',
851
+ c.type === 'number' && 'text-right tabular-nums',
852
+ ),
853
+ cell:
854
+ cells?.[c.key] ??
855
+ ((item: TItem): ReactNode => {
856
+ const v = (item as Record<string, unknown>)[c.key]
857
+ if (v === null || v === undefined || v === '') return null
858
+ if (c.type === 'date') {
859
+ // Date-only ('YYYY-MM-DD') parseia LOCAL — new Date() iria pra meia-noite
860
+ // UTC e deslocaria um dia no fuso; ISO completo segue no caminho normal.
861
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(v))
862
+ const d = m !== null
863
+ ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
864
+ : new Date(String(v))
865
+ return new Intl.DateTimeFormat('pt-BR', c.dateFormat ?? { dateStyle: 'short' }).format(d)
866
+ }
867
+ if (c.type === 'badge') return <Badge variant="outline">{String(v)}</Badge>
868
+ return typeof v === 'object' ? null : String(v)
869
+ }),
870
+ }))
871
+ }, [columns, action.columns, cells, visibleKeys])
872
+
873
+ // — Column picker: só faz sentido na tabela derivada do contrato —
874
+ const pickerColumns = columns === undefined ? (action.columns ?? []) : []
875
+ const defaultVisible = pickerColumns.filter((c) => c.hidden !== true).map((c) => c.key)
876
+ const toggleColumn = (key: string): void => {
877
+ const current = state.columns ?? defaultVisible
878
+ const next = current.includes(key) ? current.filter((k) => k !== key) : [...current, key]
879
+ // Voltou ao default → null (URL limpa).
880
+ const isDefault = next.length === defaultVisible.length && defaultVisible.every((k) => next.includes(k))
881
+ setState({ ...state, columns: isDefault ? null : next })
882
+ }
883
+
884
+ const toggleSort = (key: string): void => {
885
+ const dir = state.sort?.field === key && state.sort.dir === 'asc' ? 'desc' : 'asc'
886
+ setStateResetPage({ ...state, sort: { field: key, dir } })
887
+ }
888
+
889
+ // — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
890
+ // elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
891
+ // (não vai pra URL) e limpa quando o recorte/página muda ou um batch roda.
892
+ const batchActions = batch ?? []
893
+ const [selected, setSelected] = useState<Set<string>>(new Set())
894
+ const [running, setRunning] = useState<string | null>(null)
895
+ const [confirming, setConfirming] = useState<ActionListBatchAction<TItem> | null>(null)
896
+ const selectable = (item: TItem): boolean => batchActions.some((a) => a.can?.(item) ?? true)
897
+ const selectableItems = batchActions.length > 0 ? items.filter(selectable) : []
898
+ const hasSelection = batchActions.length > 0 && selectableItems.length > 0 && activeView === 'table'
899
+ useEffect(() => {
900
+ // Página/recorte novo → seleção limpa (os ids podem nem estar mais na tela).
901
+ setSelected(new Set())
902
+ }, [inputKey])
903
+ const selectedItems = items.filter((it) => selected.has(rowId(it)))
904
+ const eligibleFor = (a: ActionListBatchAction<TItem>): TItem[] =>
905
+ selectedItems.filter((it) => a.can?.(it) ?? true)
906
+ const runBatch = async (a: ActionListBatchAction<TItem>): Promise<void> => {
907
+ setRunning(a.label)
908
+ try {
909
+ await a.run(eligibleFor(a))
910
+ setSelected(new Set())
911
+ await refetch()
912
+ } finally {
913
+ setRunning(null)
914
+ }
915
+ }
916
+
917
+ // — Chips: só dos filtros DO MODAL (advanced + inline transbordado): os inline
918
+ // visíveis mostram o próprio estado; os do modal ficariam invisíveis sem isto —
919
+ const chips = modalSpecs
920
+ .filter(([name]) => !isEmptyValue(state.filters[name]))
921
+ .map(([name, spec]) => {
922
+ const raw = state.filters[name]
923
+ // Mesma precedência do FilterField: runtime/estáticas > dictionary do provider.
924
+ const fromProps = optionsFor(name, spec, filterOptions)
925
+ const opts = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
926
+ const display = (v: unknown): string => opts.find((o) => o.value === v)?.label ?? String(v)
927
+ const value = Array.isArray(raw) ? raw.map(display).join(', ') : display(raw)
928
+ return { name, label: text(spec.label, name), value }
929
+ })
930
+ const clearFilter = (name: string): void => {
931
+ const next = { ...state.filters }
932
+ delete next[name]
933
+ setStateResetPage({ ...state, filters: next })
934
+ }
935
+
936
+ // A busca abre a linha SÓ quando é a única forma de recorte; com período ou
937
+ // filtros no contrato, o período abre e a busca fica no grupo da direita.
938
+ const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
939
+ // Largura AUTO (todos os controles em default h-9, alinham nativamente):
940
+ // 64 com folga, encolhe até 36 no aperto — é o item flexível da linha.
941
+ const searchBox = hasSearch ? (
942
+ <ClearableInput
943
+ className="w-64 min-w-36"
944
+ leading={<Search />}
945
+ applied={state.q}
946
+ placeholder={text(action.text?.placeholder, 'Buscar…')}
947
+ onApply={(v) => {
948
+ if (v !== state.q) setStateResetPage({ ...state, q: v })
949
+ }}
950
+ />
951
+ ) : null
952
+
953
+ const toolbar = hasToolbar ? (
954
+ <div data-slot="action-list-toolbar" className="space-y-2">
955
+ {/* Linha única (sem wrap): a busca encolhe primeiro; depois os filtros inline
956
+ transbordam pro modal (medição no useLayoutEffect acima). */}
957
+ <div ref={toolbarRowRef} className="flex items-end gap-3">
958
+ {searchFirst && searchBox}
959
+ {periods.length > 0 && (
960
+ <Labeled label="Período">
961
+ <PeriodControl
962
+ periods={periods}
963
+ period={activePeriod}
964
+ from={state.from}
965
+ to={state.to}
966
+ onChange={(next) =>
967
+ setStateResetPage({
968
+ ...state,
969
+ ...next,
970
+ period: next.period === defaultPeriod ? null : next.period,
971
+ })
972
+ }
973
+ />
974
+ </Labeled>
975
+ )}
976
+ {inlineSpecs.map(([name, spec]) => (
977
+ <Labeled key={name} label={text(spec.label, name)} slot="action-list-inline-filter">
978
+ <FilterField
979
+ name={name}
980
+ spec={spec}
981
+ value={state.filters[name]}
982
+ allValues={state.filters}
983
+ onChange={(v) =>
984
+ setStateResetPage({ ...state, filters: cascadeClear(name, { ...state.filters, [name]: v }) })
985
+ }
986
+ options={optionsFor(name, spec, filterOptions)}
987
+ />
988
+ </Labeled>
989
+ ))}
990
+ {modalSpecs.length > 0 && (
991
+ <Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
992
+ <SlidersHorizontal className="h-3.5 w-3.5" />
993
+ Filtros
994
+ {modalActive > 0 && (
995
+ <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
996
+ {modalActive}
997
+ </Badge>
998
+ )}
999
+ </Button>
1000
+ )}
1001
+ {/* O grupo da direita cede por min-w-0 (sem justify-end: o vazamento no
1002
+ aperto cai pra DIREITA, onde o scrollWidth da detecção enxerga); a busca
1003
+ aqui dentro é o item flexível — encolhe antes de filtro migrar pro modal. */}
1004
+ <div className="ml-auto flex min-w-0 items-center gap-2">
1005
+ {!searchFirst && searchBox}
1006
+ {hasSegment && (
1007
+ <ToggleGroup
1008
+ type="single"
1009
+ variant="outline"
1010
+ data-slot="action-list-views"
1011
+ className="shrink-0"
1012
+ value={activeView}
1013
+ onValueChange={(key) => {
1014
+ // No single o Radix DESMARCA ao clicar no ativo (chega '') — o segment
1015
+ // de views sempre tem uma ativa, então o vazio não passa.
1016
+ if (key !== '') setState({ ...state, view: key === viewEntries[0]?.[0] ? null : key })
1017
+ }}
1018
+ >
1019
+ {viewEntries.map(([key, label, icon]) => (
1020
+ <Tooltip key={key}>
1021
+ <TooltipTrigger asChild>
1022
+ <ToggleGroupItem value={key} aria-label={label}>
1023
+ {icon ?? label}
1024
+ </ToggleGroupItem>
1025
+ </TooltipTrigger>
1026
+ <TooltipContent>{label}</TooltipContent>
1027
+ </Tooltip>
1028
+ ))}
1029
+ </ToggleGroup>
1030
+ )}
1031
+ <Tooltip>
1032
+ <TooltipTrigger asChild>
1033
+ <Button
1034
+ variant="outline"
1035
+ className="shrink-0"
1036
+ aria-label="Recarregar"
1037
+ onClick={() => void refetch()}
1038
+ disabled={isLoading}
1039
+ >
1040
+ <RefreshCw className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
1041
+ </Button>
1042
+ </TooltipTrigger>
1043
+ <TooltipContent>Recarregar</TooltipContent>
1044
+ </Tooltip>
1045
+ {/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
1046
+ tabela está ativa, itens por página sempre, e o que vier depois. */}
1047
+ <Popover>
1048
+ {/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
1049
+ <Tooltip>
1050
+ <TooltipTrigger asChild>
1051
+ <PopoverTrigger asChild>
1052
+ <Button variant="outline" className="shrink-0" aria-label="Exibição">
1053
+ <Settings2 className="h-3.5 w-3.5" />
1054
+ </Button>
1055
+ </PopoverTrigger>
1056
+ </TooltipTrigger>
1057
+ <TooltipContent>Exibição</TooltipContent>
1058
+ </Tooltip>
1059
+ <PopoverContent align="end" className="w-60 p-3" data-slot="action-list-display">
1060
+ <div className="space-y-3">
1061
+ {activeView === 'table' && pickerColumns.length > 0 && (
1062
+ <>
1063
+ <div className="space-y-2">
1064
+ <div className="flex items-center justify-between">
1065
+ <span className="text-xs font-medium text-muted-foreground">Colunas</span>
1066
+ {state.columns !== null && (
1067
+ <button
1068
+ type="button"
1069
+ onClick={() => setState({ ...state, columns: null })}
1070
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
1071
+ >
1072
+ <Eraser className="size-3" />
1073
+ Padrão
1074
+ </button>
1075
+ )}
1076
+ </div>
1077
+ <div className="space-y-1.5">
1078
+ {pickerColumns.map((c) => (
1079
+ <label key={c.key} className="flex items-center gap-2 text-sm">
1080
+ <Checkbox
1081
+ checked={(state.columns ?? defaultVisible).includes(c.key)}
1082
+ onCheckedChange={() => toggleColumn(c.key)}
1083
+ />
1084
+ {text(c.label, c.key)}
1085
+ </label>
1086
+ ))}
1087
+ </div>
1088
+ </div>
1089
+ <Separator />
1090
+ </>
1091
+ )}
1092
+ <div className="space-y-2">
1093
+ <span className="text-xs font-medium text-muted-foreground">Itens por página</span>
1094
+ <Select
1095
+ size="sm"
1096
+ className="w-full"
1097
+ value={String(state.limit ?? pageSize)}
1098
+ onChange={(v) => {
1099
+ const n = Number(v)
1100
+ // O default do caller fica FORA da URL (limit null).
1101
+ setState({ ...state, limit: n === pageSize ? null : n, page: 1 })
1102
+ }}
1103
+ options={Array.from(new Set([10, 25, 50, 100, pageSize]))
1104
+ .sort((a, b) => a - b)
1105
+ .map((n) => ({ value: String(n), label: String(n) }))}
1106
+ />
1107
+ </div>
1108
+ </div>
1109
+ </PopoverContent>
1110
+ </Popover>
1111
+ </div>
1112
+ </div>
1113
+ {chips.length > 0 && (
1114
+ <div data-slot="action-list-chips" className="flex flex-wrap items-center gap-1.5">
1115
+ {chips.map((chip) => (
1116
+ <Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
1117
+ <span className="text-muted-foreground">{chip.label}:</span> {chip.value}
1118
+ <button
1119
+ type="button"
1120
+ aria-label={`Remover filtro ${chip.label}`}
1121
+ onClick={() => clearFilter(chip.name)}
1122
+ className="rounded-sm text-muted-foreground hover:text-foreground"
1123
+ >
1124
+ <X className="size-3" />
1125
+ </button>
1126
+ </Badge>
1127
+ ))}
1128
+ </div>
1129
+ )}
1130
+ {modalSpecs.length > 0 && (
1131
+ <AdvancedFiltersDialog
1132
+ open={advancedOpen}
1133
+ onOpenChange={setAdvancedOpen}
1134
+ specs={modalSpecs}
1135
+ values={state.filters}
1136
+ cascade={cascadeClear}
1137
+ onApply={(next) => setStateResetPage({ ...state, filters: next })}
1138
+ filterOptions={filterOptions}
1139
+ />
1140
+ )}
1141
+ </div>
1142
+ ) : null
1143
+
1144
+ // — Estados de carga —
1145
+ const busy = isFetching || extraLoading === true
1146
+ const isEmpty = empty !== undefined ? empty(items) : items.length === 0
1147
+ let body: ReactNode
1148
+ if (busy && items.length === 0 && !(empty !== undefined && !isEmpty)) {
1149
+ body = (
1150
+ <div className="space-y-2">
1151
+ <Skeleton className="h-10 w-full" />
1152
+ <Skeleton className="h-10 w-full" />
1153
+ <Skeleton className="h-10 w-full" />
1154
+ </div>
1155
+ )
1156
+ } else if (isError && error !== undefined) {
1157
+ body = (
1158
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm space-y-3">
1159
+ <div>
1160
+ <strong className="text-destructive">{error.code}</strong>
1161
+ <p className="text-destructive">{error.message}</p>
1162
+ </div>
1163
+ <Button variant="outline" size="sm" onClick={() => void refetch()}>
1164
+ Tentar de novo
1165
+ </Button>
1166
+ </div>
1167
+ )
1168
+ } else if (isEmpty) {
1169
+ body = (
1170
+ <div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
1171
+ {emptyMessage}
1172
+ </div>
1173
+ )
1174
+ } else if (children !== undefined) {
1175
+ // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
1176
+ body = children(items, () => refetch())
1177
+ } else if (activeView !== 'table' && views?.[activeView] !== undefined) {
1178
+ // View alternativa (board/galeria/…): mesma fonte e toolbar, outro renderer.
1179
+ body = views[activeView].render(items, () => refetch())
1180
+ } else {
1181
+ body = (
1182
+ // Moldura da casa (igual à listagem de workspaces): quadro arredondado com o
1183
+ // scroll horizontal contido; a última linha fica sem borda pelo TableBody.
1184
+ <div className="overflow-hidden rounded-lg border border-border">
1185
+ <Table>
1186
+ <TableHeader>
1187
+ <TableRow>
1188
+ {hasSelection && (
1189
+ <TableHead className="w-px">
1190
+ <Checkbox
1191
+ aria-label="Selecionar todos os elegíveis"
1192
+ checked={selectableItems.length > 0 && selectableItems.every((it) => selected.has(rowId(it)))}
1193
+ onCheckedChange={(v) =>
1194
+ setSelected(v === true ? new Set(selectableItems.map(rowId)) : new Set())
1195
+ }
1196
+ />
1197
+ </TableHead>
1198
+ )}
1199
+ {derived.map((col) => (
1200
+ <TableHead key={col.key} className={col.className}>
1201
+ {col.sortable === true ? (
1202
+ <button
1203
+ type="button"
1204
+ onClick={() => toggleSort(col.key)}
1205
+ className="inline-flex items-center gap-1 hover:text-foreground"
1206
+ >
1207
+ {col.header}
1208
+ {state.sort?.field === col.key ? (
1209
+ state.sort.dir === 'asc' ? (
1210
+ <ArrowUp className="size-3.5" />
1211
+ ) : (
1212
+ <ArrowDown className="size-3.5" />
1213
+ )
1214
+ ) : (
1215
+ <ArrowUpDown className="size-3.5 opacity-40" />
1216
+ )}
1217
+ </button>
1218
+ ) : (
1219
+ col.header
1220
+ )}
1221
+ </TableHead>
1222
+ ))}
1223
+ {rowActions !== undefined && <TableHead className="w-px" />}
1224
+ </TableRow>
1225
+ </TableHeader>
1226
+ <TableBody>
1227
+ {items.map((item, idx) => (
1228
+ <TableRow
1229
+ // eslint-disable-next-line react/no-array-index-key
1230
+ key={idx}
1231
+ className={onRowClick !== undefined ? 'cursor-pointer' : undefined}
1232
+ onClick={onRowClick !== undefined ? () => onRowClick(item) : undefined}
1233
+ >
1234
+ {hasSelection && (
1235
+ <TableCell className="w-px" onClick={(e) => e.stopPropagation()}>
1236
+ <Checkbox
1237
+ aria-label="Selecionar linha"
1238
+ disabled={!selectable(item)}
1239
+ checked={selected.has(rowId(item))}
1240
+ onCheckedChange={(v) => {
1241
+ const next = new Set(selected)
1242
+ if (v === true) next.add(rowId(item))
1243
+ else next.delete(rowId(item))
1244
+ setSelected(next)
1245
+ }}
1246
+ />
1247
+ </TableCell>
1248
+ )}
1249
+ {derived.map((col) => (
1250
+ <TableCell key={col.key} className={col.className}>
1251
+ {col.cell(item)}
1252
+ </TableCell>
1253
+ ))}
1254
+ {rowActions !== undefined && (
1255
+ <TableCell className="w-px whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
1256
+ {rowActions(item)}
1257
+ </TableCell>
1258
+ )}
1259
+ </TableRow>
1260
+ ))}
1261
+ </TableBody>
1262
+ </Table>
1263
+ </div>
1264
+ )
1265
+ }
1266
+
1267
+ // — Barra de seleção (batch): aparece só com itens marcados —
1268
+ const selectionBar =
1269
+ hasSelection && selected.size > 0 ? (
1270
+ <div
1271
+ data-slot="action-list-selection"
1272
+ className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
1273
+ >
1274
+ <span>
1275
+ {selected.size} {selected.size === 1 ? 'selecionado' : 'selecionados'}
1276
+ </span>
1277
+ <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
1278
+ Limpar
1279
+ </Button>
1280
+ <div className="ml-auto flex items-center gap-2">
1281
+ {batchActions.map((a) => {
1282
+ const eligible = eligibleFor(a).length
1283
+ return (
1284
+ <Button
1285
+ key={a.label}
1286
+ size="sm"
1287
+ variant={a.destructive === true ? 'destructive' : 'outline'}
1288
+ disabled={eligible === 0}
1289
+ busy={running === a.label}
1290
+ onClick={() => {
1291
+ if (a.confirm !== undefined) setConfirming(a)
1292
+ else void runBatch(a)
1293
+ }}
1294
+ >
1295
+ {a.label}
1296
+ {eligible > 0 && eligible < selected.size ? ` (${eligible})` : ''}
1297
+ </Button>
1298
+ )
1299
+ })}
1300
+ </div>
1301
+ </div>
1302
+ ) : null
1303
+
1304
+ // — Rodapé (o estilo da casa): Página X de Y à esquerda · pager CENTRALIZADO ·
1305
+ // total à direita (grid de 3 colunas com laterais 1fr = centro geométrico) —
1306
+ const pages = total !== undefined ? Math.max(1, Math.ceil(total / (state.limit ?? pageSize))) : 1
1307
+ const goTo = (page: number): void => setState({ ...state, page })
1308
+ const footer =
1309
+ total !== undefined && !isError ? (
1310
+ <div
1311
+ data-slot="action-list-footer"
1312
+ className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-3 py-1.5 text-xs text-muted-foreground"
1313
+ >
1314
+ <span>
1315
+ Página {state.page} de {pages}
1316
+ </span>
1317
+ {pages > 1 && (
1318
+ <div className="flex items-center gap-0.5">
1319
+ <Button
1320
+ size="sm"
1321
+ variant="ghost"
1322
+ className="size-7 p-0"
1323
+ aria-label="Página anterior"
1324
+ disabled={state.page <= 1}
1325
+ onClick={() => goTo(state.page - 1)}
1326
+ >
1327
+ <ChevronLeft className="size-3.5" />
1328
+ </Button>
1329
+ {pageWindow(state.page, pages).map((n, i) =>
1330
+ n === '…' ? (
1331
+ // eslint-disable-next-line react/no-array-index-key
1332
+ <span key={`e${i}`} className="px-1">
1333
+
1334
+ </span>
1335
+ ) : (
1336
+ <Button
1337
+ key={n}
1338
+ size="sm"
1339
+ variant={n === state.page ? 'secondary' : 'ghost'}
1340
+ className="size-7 p-0"
1341
+ aria-current={n === state.page ? 'page' : undefined}
1342
+ onClick={() => goTo(n)}
1343
+ >
1344
+ {n}
1345
+ </Button>
1346
+ ),
1347
+ )}
1348
+ <Button
1349
+ size="sm"
1350
+ variant="ghost"
1351
+ className="size-7 p-0"
1352
+ aria-label="Próxima página"
1353
+ disabled={state.page >= pages}
1354
+ onClick={() => goTo(state.page + 1)}
1355
+ >
1356
+ <ChevronRight className="size-3.5" />
1357
+ </Button>
1358
+ </div>
1359
+ )}
1360
+ {/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
1361
+ {pages <= 1 && <span />}
1362
+ <span className="justify-self-end">
1363
+ {total} {total === 1 ? 'item' : 'itens'} no total
1364
+ </span>
1365
+ </div>
1366
+ ) : null
1367
+
1368
+ return (
1369
+ <TooltipProvider>
1370
+ <div data-action={action.name} className="space-y-3">
1371
+ {toolbar}
1372
+ {selectionBar}
1373
+ {/* Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
1374
+ <div aria-busy={busy} className={cn(busy && items.length > 0 && 'pointer-events-none opacity-60')}>
1375
+ {body}
1376
+ </div>
1377
+ {footer}
1378
+ {confirming !== null && (
1379
+ <AlertDialog
1380
+ open
1381
+ onOpenChange={(o) => {
1382
+ if (!o) setConfirming(null)
1383
+ }}
1384
+ >
1385
+ <AlertDialogContent>
1386
+ <AlertDialogHeader>
1387
+ <AlertDialogTitle>{confirming.confirm?.title ?? confirming.label}</AlertDialogTitle>
1388
+ {confirming.confirm?.message !== undefined && (
1389
+ <AlertDialogDescription>{confirming.confirm.message}</AlertDialogDescription>
1390
+ )}
1391
+ </AlertDialogHeader>
1392
+ <AlertDialogFooter>
1393
+ <Button variant="outline" size="sm" onClick={() => setConfirming(null)}>
1394
+ Cancelar
1395
+ </Button>
1396
+ <Button
1397
+ size="sm"
1398
+ variant={confirming.destructive === true ? 'destructive' : 'default'}
1399
+ onClick={() => {
1400
+ const a = confirming
1401
+ setConfirming(null)
1402
+ void runBatch(a)
1403
+ }}
1404
+ >
1405
+ {confirming.label}
1406
+ </Button>
1407
+ </AlertDialogFooter>
1408
+ </AlertDialogContent>
1409
+ </AlertDialog>
1410
+ )}
1411
+ </div>
1412
+ </TooltipProvider>
1413
+ )
1414
+ }
1415
+
1416
+ // =============================================================================
1417
+ // URL sync — a serialização padrão do estado da toolbar (querystring compacta)
1418
+ // =============================================================================
1419
+ //
1420
+ // Convenção: `q` · `sort=chave:dir` (omitido quando é o default do contrato) ·
1421
+ // `period=preset` relativo ou `period=from..to` (o custom é implícito; default
1422
+ // omitido) · `view` (omitida quando é a primeira) · `cols=a,b,c` (omitida no
1423
+ // default) · filtros pelo PRÓPRIO nome (multiple → CSV). Quem embala liga:
1424
+ // <ActionList state={listParamsToState(params, contract)}
1425
+ // onStateChange={(s) => navigate(`/rota${listStateToParams(s, contract)}`)} />
1426
+
1427
+ /** URLSearchParams → estado da toolbar (lê a convenção acima). */
1428
+ export function listParamsToState(params: URLSearchParams, action: ListActionLike): ActionListState {
1429
+ const defaultSort = action.sort?.default?.[0] ?? null
1430
+ const sortParam = params.get('sort')
1431
+ let sort: SortSpec | null = defaultSort
1432
+ if (sortParam !== null) {
1433
+ const [field, dir] = sortParam.split(':')
1434
+ if (field !== undefined && field !== '') sort = { field, dir: dir === 'asc' ? 'asc' : 'desc' }
1435
+ }
1436
+ const filters: Record<string, unknown> = {}
1437
+ for (const [name, spec] of Object.entries(action.filters ?? {})) {
1438
+ const v = params.get(name)
1439
+ if (v !== null && v !== '') filters[name] = spec.multiple === true ? v.split(',') : v
1440
+ }
1441
+ const cols = params.get('cols')
1442
+ const limitParam = Number(params.get('limit') ?? '')
1443
+ const page = Number(params.get('page') ?? '1')
1444
+ // Range direto no param ('period=2026-07-01..2026-07-07') = custom implícito.
1445
+ const periodParam = params.get('period')
1446
+ const range = periodParam?.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/) ?? null
1447
+ return {
1448
+ q: params.get('q') ?? '',
1449
+ sort,
1450
+ filters,
1451
+ period: range !== null ? 'custom' : periodParam,
1452
+ from: range?.[1] ?? params.get('from'),
1453
+ to: range?.[2] ?? params.get('to'),
1454
+ view: params.get('view'),
1455
+ columns: cols !== null && cols !== '' ? cols.split(',') : null,
1456
+ limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
1457
+ page: Number.isInteger(page) && page > 1 ? page : 1,
1458
+ }
1459
+ }
1460
+
1461
+ /** Estado da toolbar → querystring ('' ou '?…'), omitindo defaults (URL limpa). */
1462
+ export function listStateToParams(state: ActionListState, action: ListActionLike): string {
1463
+ const qs = new URLSearchParams()
1464
+ if (state.q.trim() !== '') qs.set('q', state.q.trim())
1465
+ const d = action.sort?.default?.[0]
1466
+ if (state.sort !== null && !(d !== undefined && state.sort.field === d.field && state.sort.dir === d.dir)) {
1467
+ qs.set('sort', `${state.sort.field}:${state.sort.dir}`)
1468
+ }
1469
+ for (const [name, value] of Object.entries(state.filters)) {
1470
+ if (isEmptyValue(value)) continue
1471
+ qs.set(name, Array.isArray(value) ? value.join(',') : String(value))
1472
+ }
1473
+ if (state.period === 'custom') {
1474
+ // O custom é implícito: o range vai direto no param ('period=from..to').
1475
+ if (state.from !== null && state.to !== null) qs.set('period', `${state.from}..${state.to}`)
1476
+ } else if (state.period !== null) {
1477
+ // Preset é relativo (materializa no fetch).
1478
+ qs.set('period', state.period)
1479
+ }
1480
+ if (state.view !== null) qs.set('view', state.view)
1481
+ if (state.columns !== null) qs.set('cols', state.columns.join(','))
1482
+ if (state.limit !== null) qs.set('limit', String(state.limit))
1483
+ if (state.page > 1) qs.set('page', String(state.page))
1484
+ const s = qs.toString()
1485
+ return s === '' ? '' : `?${s}`
1486
+ }
1487
+
1488
+ export type { Paginated, ListAction }