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