@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.
- package/CHANGELOG.md +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- 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 }
|