@softize/opus 30.0.0 → 31.1.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 +123 -0
- package/bin/lib/check.mjs +109 -101
- package/bin/lib/copy.mjs +2973 -1801
- package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
- package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
- package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
- package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
- package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
- package/docs/adr/0066-civil-date-input-and-calendar-list.md +28 -0
- package/docs/adr/README.md +6 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +21 -19
- package/registry/skills/build-opus-ui/references/evaluations.md +13 -11
- package/registry/skills/build-opus-ui/references/ui-patterns.md +24 -27
- package/src/core/presentation.ts +938 -32
- package/src/core/types.ts +2 -0
- package/src/ui/components/patterns/form.tsx +22 -1
- package/src/ui/components/patterns/grouped-list.ts +2 -2
- package/src/ui/components/patterns/list-calendar.tsx +129 -0
- package/src/ui/components/patterns/list.tsx +612 -382
- package/src/ui/components/patterns/page-heading-context.tsx +12 -5
- package/src/ui/components/patterns/page.tsx +227 -197
- package/src/ui/components/patterns/presentation-fields.tsx +246 -0
- package/src/ui/components/patterns/presentation-sections.tsx +494 -0
- package/src/ui/components/patterns/presentation.tsx +1142 -229
- package/src/ui/components/patterns/structural-header.tsx +23 -32
- package/src/ui/components/patterns/surface-anatomy.ts +27 -217
- package/src/ui/components/patterns/surface.tsx +19 -29
- package/src/ui/components/patterns/trigger.tsx +10 -2
- package/src/ui/components/primitives/calendar.tsx +39 -18
- package/src/ui/components/primitives/close-button.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +2 -8
- package/src/ui/components/primitives/date-input.tsx +276 -0
- package/src/ui/components/primitives/detail.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +19 -81
- package/src/ui/components/primitives/drawer.tsx +17 -79
- package/src/ui/components/primitives/field.tsx +1 -1
- package/src/ui/components/primitives/pagination.tsx +3 -2
- package/src/ui/components/primitives/table.tsx +1 -1
- package/src/ui/components/primitives/tabs.tsx +13 -2
- package/src/ui/docs/content/action-form.md +6 -1
- package/src/ui/docs/content/action-list.md +156 -68
- package/src/ui/docs/content/action-trigger.md +5 -0
- package/src/ui/docs/content/button.md +5 -6
- package/src/ui/docs/content/calendar-month-view.md +50 -0
- package/src/ui/docs/content/calendar.md +6 -1
- package/src/ui/docs/content/command.md +62 -50
- package/src/ui/docs/content/compositions.md +129 -459
- package/src/ui/docs/content/date-input.md +67 -0
- package/src/ui/docs/content/dialog.md +44 -112
- package/src/ui/docs/content/drawer.md +44 -103
- package/src/ui/docs/content/field.md +2 -1
- package/src/ui/docs/content/input.md +3 -0
- package/src/ui/docs/content/page.md +204 -412
- package/src/ui/docs/content/presentation.md +371 -23
- package/src/ui/docs/content/separator.md +1 -1
- package/src/ui/docs/content/surface.md +65 -120
- package/src/ui/docs/content/tokens.md +18 -4
- package/src/ui/docs/registry.tsx +38 -9
- package/src/ui/drivers/context.tsx +11 -4
- package/src/ui/drivers/react.tsx +27 -7
- package/src/ui/meta.ts +17 -6
- package/src/ui/react.tsx +41 -8
- package/src/ui/theme.css +44 -33
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* quem embala sincronizar com a URL). O `input` da prop é o ESCOPO BASE (ex.:
|
|
15
15
|
* { workspaceId }) — a toolbar soma por cima e nunca o sobrescreve.
|
|
16
16
|
*
|
|
17
|
-
* Modos de render: tabela derivada (default) · `columns` prop (tabela explícita,
|
|
17
|
+
* Modos de render: itens por campos declarados · tabela derivada (default) · `columns` prop (tabela explícita,
|
|
18
18
|
* legado) · children `(items, refetch) => nó` (layout livre; a toolbar continua).
|
|
19
19
|
* Empty/loading/error padronizados. `data-action` na raiz (selector E2E).
|
|
20
20
|
*/
|
|
@@ -63,7 +63,8 @@ import {
|
|
|
63
63
|
import {
|
|
64
64
|
useDicts,
|
|
65
65
|
useGroupedListAction,
|
|
66
|
-
|
|
66
|
+
useListResource,
|
|
67
|
+
type ListDataSource,
|
|
67
68
|
useLookupAction,
|
|
68
69
|
type DictLike,
|
|
69
70
|
} from "../../drivers/react.tsx";
|
|
@@ -123,6 +124,15 @@ import {
|
|
|
123
124
|
type FieldSetVariant,
|
|
124
125
|
} from "../primitives/field.tsx";
|
|
125
126
|
import { Surface } from "./surface.tsx";
|
|
127
|
+
import {
|
|
128
|
+
Item,
|
|
129
|
+
ItemGroup,
|
|
130
|
+
ItemHeader,
|
|
131
|
+
ItemTitle,
|
|
132
|
+
ItemDescription,
|
|
133
|
+
ItemActions,
|
|
134
|
+
ItemSeparator,
|
|
135
|
+
} from "../primitives/item.tsx";
|
|
126
136
|
import { Search } from "lucide-react";
|
|
127
137
|
import { Select, type SelectOption } from "../primitives/select.tsx";
|
|
128
138
|
import {
|
|
@@ -266,6 +276,7 @@ export function presetRange(
|
|
|
266
276
|
/** Estrutural de propósito (como o useListAction): aceita o CONTRATO compartilhado
|
|
267
277
|
* (sem handler) e a action completa — só o que a UI deriva importa aqui. */
|
|
268
278
|
export interface ListActionLike {
|
|
279
|
+
paginate?: "cursor" | "offset" | false;
|
|
269
280
|
name: string;
|
|
270
281
|
kind: "list";
|
|
271
282
|
/** Schema de saída (item). Coluna cujo campo carrega a meta de `t.dict` renderiza
|
|
@@ -304,6 +315,8 @@ export interface ActionFilterBarProps {
|
|
|
304
315
|
refreshing?: boolean;
|
|
305
316
|
/** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
|
|
306
317
|
periodPosition?: "before" | "after" | { afterFilter: string };
|
|
318
|
+
/** Substitui o seletor de período; estado e input temporal ficam com o consumidor. */
|
|
319
|
+
periodControl?: ReactNode;
|
|
307
320
|
/** Composição do período com uma dimensão temporal declarada como filtro inline. */
|
|
308
321
|
period?: PeriodLayout;
|
|
309
322
|
/** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
|
|
@@ -349,8 +362,34 @@ export interface ActionListBatchAction<TItem> {
|
|
|
349
362
|
destructive?: boolean;
|
|
350
363
|
}
|
|
351
364
|
|
|
352
|
-
|
|
353
|
-
|
|
365
|
+
/** Campos da apresentação de itens; serializável sem callbacks ou JSX. */
|
|
366
|
+
export interface ActionListItemSpec {
|
|
367
|
+
title: string;
|
|
368
|
+
description?: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface ActionListContext<TItem> {
|
|
372
|
+
items: TItem[];
|
|
373
|
+
total: number | undefined;
|
|
374
|
+
isFetching: boolean;
|
|
375
|
+
isError: boolean;
|
|
376
|
+
refetch: () => Promise<void>;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export type ActionListProps<TInput, TItem> = ActionListBaseProps<
|
|
380
|
+
TInput,
|
|
381
|
+
TItem
|
|
382
|
+
> &
|
|
383
|
+
(
|
|
384
|
+
| { action: ListActionLike; source?: never }
|
|
385
|
+
| { source: ListDataSource<TItem>; action?: never }
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
interface ActionListBaseProps<TInput, TItem> {
|
|
389
|
+
/** Título da coleção; as ações da toolbar acompanham seu cabeçalho. */
|
|
390
|
+
title?: string;
|
|
391
|
+
/** Apresenta itens conectados em uma moldura, em vez de colunas de tabela. */
|
|
392
|
+
item?: ActionListItemSpec;
|
|
354
393
|
/** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
|
|
355
394
|
input: TInput;
|
|
356
395
|
/**
|
|
@@ -360,8 +399,9 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
360
399
|
* `flow` nos demais casos (ADR 0024).
|
|
361
400
|
*/
|
|
362
401
|
layout?: "flow" | "fill";
|
|
363
|
-
/** Ações
|
|
364
|
-
toolbarActions?:
|
|
402
|
+
/** Ações da coleção (criar, importar, exportar) após os controles de exibição. */
|
|
403
|
+
toolbarActions?:
|
|
404
|
+
ReactNode | ((context: ActionListContext<TItem>) => ReactNode);
|
|
365
405
|
/** Controles da visualização ativa, imediatamente após seu seletor. */
|
|
366
406
|
viewControls?: ReactNode;
|
|
367
407
|
/** Tabela explícita — sobrepõe as colunas do contrato. */
|
|
@@ -378,6 +418,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
378
418
|
advancedFilters?: AdvancedFiltersLayout;
|
|
379
419
|
/** Posição do período em relação aos filtros inline. Também pode ancorá-lo após um filtro. */
|
|
380
420
|
periodPosition?: "before" | "after" | { afterFilter: string };
|
|
421
|
+
/** Substitui o seletor de período; estado e input temporal ficam com o consumidor. */
|
|
422
|
+
periodControl?: ReactNode;
|
|
381
423
|
/** Composição do período com uma dimensão temporal declarada como filtro inline. */
|
|
382
424
|
period?: PeriodLayout;
|
|
383
425
|
/** Views alternativas da mesma listagem — o segment na toolbar alterna entre a
|
|
@@ -404,8 +446,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
404
446
|
/** Click handler por linha (ex: navegar pro detalhe) — só na tabela. */
|
|
405
447
|
onRowClick?: (item: TItem) => void;
|
|
406
448
|
/** Ações POR LINHA (coluna final, alinhada à direita) — apresentação de quem chama,
|
|
407
|
-
* como `cells`. Cliques ali não disparam o onRowClick.
|
|
408
|
-
rowActions?: (item: TItem) => ReactNode;
|
|
449
|
+
* como `cells`. Cliques ali não disparam o onRowClick. Também aparece à direita dos itens. */
|
|
450
|
+
rowActions?: (item: TItem, context: ActionListContext<TItem>) => ReactNode;
|
|
409
451
|
/**
|
|
410
452
|
* Conteúdo entre a toolbar e o corpo da listagem — ex.: o resumo do recorte atual
|
|
411
453
|
* ("12 itens, R$ 4.300 no período"), que precisa reagir ao filtro e por isso não cabe
|
|
@@ -415,7 +457,7 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
415
457
|
* enquanto viveu assim, todo bump do Opus o descartava em silêncio e o app parava de
|
|
416
458
|
* compilar sem ninguém entender por quê. Subiu pra cá em 8.6.9.
|
|
417
459
|
*/
|
|
418
|
-
beforeContent?: ReactNode;
|
|
460
|
+
beforeContent?: ReactNode | ((input: TInput) => ReactNode);
|
|
419
461
|
/** Modo COMPOSIÇÃO: layout livre ÚNICO (sem segment); toolbar e estados seguem. */
|
|
420
462
|
children?: (items: TItem[], refetch: () => Promise<void>) => ReactNode;
|
|
421
463
|
/** Estado da toolbar CONTROLADO (URL sync de quem embala). Default: interno. */
|
|
@@ -1376,6 +1418,7 @@ export function ActionFilterBar({
|
|
|
1376
1418
|
onRefresh,
|
|
1377
1419
|
refreshing = false,
|
|
1378
1420
|
periodPosition = "before",
|
|
1421
|
+
periodControl: customPeriodControl,
|
|
1379
1422
|
period,
|
|
1380
1423
|
controls,
|
|
1381
1424
|
actions,
|
|
@@ -1395,8 +1438,9 @@ export function ActionFilterBar({
|
|
|
1395
1438
|
periods[0]?.value ??
|
|
1396
1439
|
null;
|
|
1397
1440
|
const activePeriod = state.period ?? defaultPeriod;
|
|
1441
|
+
const hasPeriod = customPeriodControl != null || periods.length > 0;
|
|
1398
1442
|
const periodBasisEntry =
|
|
1399
|
-
|
|
1443
|
+
hasPeriod && period !== undefined
|
|
1400
1444
|
? inlineSpecs.find(([name]) => name === period.basisFilter)
|
|
1401
1445
|
: undefined;
|
|
1402
1446
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
@@ -1446,18 +1490,18 @@ export function ActionFilterBar({
|
|
|
1446
1490
|
});
|
|
1447
1491
|
const inlineControlCount =
|
|
1448
1492
|
inlineSpecs.filter(([name]) => name !== periodBasisEntry?.[0]).length +
|
|
1449
|
-
(
|
|
1493
|
+
(hasPeriod ? 1 : 0) +
|
|
1450
1494
|
(hasSearch ? 1 : 0);
|
|
1451
1495
|
const showInlineLabels = inlineControlCount > 1;
|
|
1452
|
-
const searchAtActionsEnd =
|
|
1453
|
-
hasSearch && (inlineSpecs.length > 0 || periods.length > 0);
|
|
1496
|
+
const searchAtActionsEnd = hasSearch && (inlineSpecs.length > 0 || hasPeriod);
|
|
1454
1497
|
const periodAfterFilter =
|
|
1455
1498
|
typeof periodPosition === "object" ? periodPosition.afterFilter : null;
|
|
1456
1499
|
const anchoredPeriodExists = inlineSpecs.some(
|
|
1457
1500
|
([name]) => name === periodAfterFilter,
|
|
1458
1501
|
);
|
|
1459
1502
|
const periodField =
|
|
1460
|
-
|
|
1503
|
+
customPeriodControl ??
|
|
1504
|
+
(periods.length > 0 ? (
|
|
1461
1505
|
<PeriodControl
|
|
1462
1506
|
periods={periods}
|
|
1463
1507
|
period={activePeriod}
|
|
@@ -1480,7 +1524,7 @@ export function ActionFilterBar({
|
|
|
1480
1524
|
})
|
|
1481
1525
|
}
|
|
1482
1526
|
/>
|
|
1483
|
-
) : null;
|
|
1527
|
+
) : null);
|
|
1484
1528
|
const periodControl =
|
|
1485
1529
|
periodField === null || periodBasisEntry !== undefined ? null : (
|
|
1486
1530
|
<Labeled
|
|
@@ -1547,24 +1591,26 @@ export function ActionFilterBar({
|
|
|
1547
1591
|
aria-hidden="true"
|
|
1548
1592
|
/>
|
|
1549
1593
|
<div className="pl-2">
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1594
|
+
{customPeriodControl ?? (
|
|
1595
|
+
<PeriodControl
|
|
1596
|
+
periods={periods}
|
|
1597
|
+
period={activePeriod}
|
|
1598
|
+
from={state.from}
|
|
1599
|
+
to={state.to}
|
|
1600
|
+
clearable={false}
|
|
1601
|
+
embedded
|
|
1602
|
+
onChange={(next) =>
|
|
1603
|
+
onStateChange({
|
|
1604
|
+
...state,
|
|
1605
|
+
...next,
|
|
1606
|
+
period:
|
|
1607
|
+
next.period === defaultPeriod
|
|
1608
|
+
? null
|
|
1609
|
+
: next.period,
|
|
1610
|
+
})
|
|
1611
|
+
}
|
|
1612
|
+
/>
|
|
1613
|
+
)}
|
|
1568
1614
|
</div>
|
|
1569
1615
|
<Separator
|
|
1570
1616
|
data-slot="action-filter-period-separator"
|
|
@@ -1778,7 +1824,10 @@ function RowActions({ children }: { children: ReactNode }) {
|
|
|
1778
1824
|
}
|
|
1779
1825
|
|
|
1780
1826
|
export function ActionList<TInput, TItem>({
|
|
1781
|
-
action,
|
|
1827
|
+
action: actionProp,
|
|
1828
|
+
source,
|
|
1829
|
+
title,
|
|
1830
|
+
item: itemSpec,
|
|
1782
1831
|
input,
|
|
1783
1832
|
layout: layoutProp,
|
|
1784
1833
|
toolbarActions,
|
|
@@ -1790,6 +1839,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1790
1839
|
filterIcons,
|
|
1791
1840
|
advancedFilters,
|
|
1792
1841
|
periodPosition,
|
|
1842
|
+
periodControl,
|
|
1793
1843
|
period,
|
|
1794
1844
|
views,
|
|
1795
1845
|
batch,
|
|
@@ -1807,16 +1857,40 @@ export function ActionList<TInput, TItem>({
|
|
|
1807
1857
|
state: controlledState,
|
|
1808
1858
|
onStateChange,
|
|
1809
1859
|
}: ActionListProps<TInput, TItem>) {
|
|
1860
|
+
if (source !== undefined && actionProp !== undefined)
|
|
1861
|
+
throw new Error("ActionList aceita action ou source, nunca ambos.");
|
|
1862
|
+
if (source !== undefined && source.key.length === 0)
|
|
1863
|
+
throw new Error("A fonte de ActionList exige uma chave com escopo.");
|
|
1864
|
+
if (
|
|
1865
|
+
itemSpec !== undefined &&
|
|
1866
|
+
(children !== undefined || views !== undefined || batch !== undefined)
|
|
1867
|
+
)
|
|
1868
|
+
throw new Error(
|
|
1869
|
+
"ActionList em itens não combina children, views ou seleção em lote.",
|
|
1870
|
+
);
|
|
1871
|
+
const action: ListActionLike = actionProp ?? {
|
|
1872
|
+
name: "external",
|
|
1873
|
+
kind: "list",
|
|
1874
|
+
};
|
|
1875
|
+
const paginated = action.paginate !== false;
|
|
1810
1876
|
const confirmBodyId = useId();
|
|
1811
1877
|
const groupRegionId = useId();
|
|
1878
|
+
const optionsPanelRef = useRef<HTMLDivElement>(null);
|
|
1812
1879
|
const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
|
|
1813
1880
|
// Query-backed (useListAction): busca declarativa por [name, input] E refetch
|
|
1814
1881
|
// automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
|
|
1815
1882
|
// O input efetivo é computado abaixo; o hook re-busca quando ele muda.
|
|
1816
|
-
const hasTable =
|
|
1817
|
-
|
|
1883
|
+
const hasTable =
|
|
1884
|
+
itemSpec === undefined &&
|
|
1885
|
+
(columns !== undefined || action.columns !== undefined);
|
|
1886
|
+
if (
|
|
1887
|
+
itemSpec === undefined &&
|
|
1888
|
+
children === undefined &&
|
|
1889
|
+
!hasTable &&
|
|
1890
|
+
views === undefined
|
|
1891
|
+
) {
|
|
1818
1892
|
throw new Error(
|
|
1819
|
-
"ActionList precisa de children (composição), columns (prop/contrato) ou views.",
|
|
1893
|
+
"ActionList precisa de item, children (composição), columns (prop/contrato) ou views.",
|
|
1820
1894
|
);
|
|
1821
1895
|
}
|
|
1822
1896
|
|
|
@@ -1883,6 +1957,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1883
1957
|
});
|
|
1884
1958
|
const requestedGroupBy = state.groupBy ?? null;
|
|
1885
1959
|
const activeGroupBy =
|
|
1960
|
+
itemSpec === undefined &&
|
|
1886
1961
|
children === undefined &&
|
|
1887
1962
|
activeView === "table" &&
|
|
1888
1963
|
requestedGroupBy !== null &&
|
|
@@ -1893,62 +1968,29 @@ export function ActionList<TInput, TItem>({
|
|
|
1893
1968
|
activeGroupBy === null
|
|
1894
1969
|
? null
|
|
1895
1970
|
: text(action.filters?.[activeGroupBy]?.label, activeGroupBy);
|
|
1896
|
-
const hasSegment =
|
|
1971
|
+
const hasSegment =
|
|
1972
|
+
itemSpec === undefined && children === undefined && viewEntries.length > 1;
|
|
1897
1973
|
const periods = action.periods ?? [];
|
|
1898
1974
|
const hasToolbar =
|
|
1899
1975
|
hasSearch ||
|
|
1900
1976
|
filterSpecs.length > 0 ||
|
|
1901
1977
|
periods.length > 0 ||
|
|
1978
|
+
periodControl != null ||
|
|
1902
1979
|
hasSegment ||
|
|
1903
1980
|
viewControls != null ||
|
|
1981
|
+
(title === undefined && toolbarActions != null) ||
|
|
1904
1982
|
hasTable;
|
|
1905
1983
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
filter?.type === "date" &&
|
|
1917
|
-
!isValidDateFilterValue(v, filter.multiple === true)
|
|
1918
|
-
)
|
|
1919
|
-
continue;
|
|
1920
|
-
filled[name] = v;
|
|
1921
|
-
}
|
|
1922
|
-
// Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
|
|
1923
|
-
// range. state.period null = o default do contrato (sempre aplicado).
|
|
1924
|
-
const effectivePeriod =
|
|
1925
|
-
state.period ??
|
|
1926
|
-
action.periods?.find((p) => p.default === true)?.value ??
|
|
1927
|
-
action.periods?.[0]?.value ??
|
|
1928
|
-
null;
|
|
1929
|
-
let range: { from: string; to: string } | null = null;
|
|
1930
|
-
if (
|
|
1931
|
-
effectivePeriod === "custom" &&
|
|
1932
|
-
state.from !== null &&
|
|
1933
|
-
state.to !== null
|
|
1934
|
-
) {
|
|
1935
|
-
range = { from: state.from, to: state.to };
|
|
1936
|
-
} else if (effectivePeriod !== null && effectivePeriod !== "custom") {
|
|
1937
|
-
range = presetRange(effectivePeriod);
|
|
1938
|
-
}
|
|
1939
|
-
return {
|
|
1940
|
-
...(input as Record<string, unknown>),
|
|
1941
|
-
...filled,
|
|
1942
|
-
...(range !== null ? range : {}),
|
|
1943
|
-
...(state.q.trim() !== "" ? { q: state.q.trim() } : {}),
|
|
1944
|
-
...(state.sort !== null
|
|
1945
|
-
? { sort: `${state.sort.field}:${state.sort.dir}` }
|
|
1946
|
-
: {}),
|
|
1947
|
-
// Paginação server-driven: o handler implementa OFFSET e devolve `total`.
|
|
1948
|
-
limit: state.limit ?? pageSize,
|
|
1949
|
-
page: state.page,
|
|
1950
|
-
} as TInput;
|
|
1951
|
-
}, [input, state, pageSize, filterDefaults]);
|
|
1984
|
+
const effectiveInput = useMemo(
|
|
1985
|
+
() =>
|
|
1986
|
+
listStateToInput(state, action, {
|
|
1987
|
+
input: input as Record<string, unknown>,
|
|
1988
|
+
filterDefaults,
|
|
1989
|
+
pageSize,
|
|
1990
|
+
applyPeriod: periodControl == null,
|
|
1991
|
+
}) as TInput,
|
|
1992
|
+
[input, state, pageSize, filterDefaults, action, periodControl],
|
|
1993
|
+
);
|
|
1952
1994
|
|
|
1953
1995
|
const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
|
|
1954
1996
|
const groupSpec =
|
|
@@ -1986,8 +2028,8 @@ export function ActionList<TInput, TItem>({
|
|
|
1986
2028
|
}>({ key: "", values: new Set() });
|
|
1987
2029
|
const collapsedGroups =
|
|
1988
2030
|
groupCollapse.key === inputKey ? groupCollapse.values : new Set<string>();
|
|
1989
|
-
const flat =
|
|
1990
|
-
|
|
2031
|
+
const flat = useListResource<TItem, Record<string, unknown>>(
|
|
2032
|
+
source ?? action,
|
|
1991
2033
|
effectiveInput as Record<string, unknown>,
|
|
1992
2034
|
{ enabled: activeGroupBy === null },
|
|
1993
2035
|
);
|
|
@@ -2050,6 +2092,17 @@ export function ActionList<TInput, TItem>({
|
|
|
2050
2092
|
if (activeGroupBy === null) return flat.refetch();
|
|
2051
2093
|
await Promise.all(grouped.map((group) => group.refetch()));
|
|
2052
2094
|
};
|
|
2095
|
+
const listContext: ActionListContext<TItem> = {
|
|
2096
|
+
items,
|
|
2097
|
+
total,
|
|
2098
|
+
isFetching,
|
|
2099
|
+
isError,
|
|
2100
|
+
refetch,
|
|
2101
|
+
};
|
|
2102
|
+
const collectionActions =
|
|
2103
|
+
typeof toolbarActions === "function"
|
|
2104
|
+
? toolbarActions(listContext)
|
|
2105
|
+
: toolbarActions;
|
|
2053
2106
|
// — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
|
|
2054
2107
|
// picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
|
|
2055
2108
|
const visibleKeys = state.columns;
|
|
@@ -2164,11 +2217,16 @@ export function ActionList<TInput, TItem>({
|
|
|
2164
2217
|
const displayCustomized =
|
|
2165
2218
|
activeGroupBy !== null ||
|
|
2166
2219
|
state.columns !== null ||
|
|
2167
|
-
state.limit !== null ||
|
|
2220
|
+
(paginated && state.limit !== null) ||
|
|
2168
2221
|
sortCustomized;
|
|
2169
|
-
const
|
|
2170
|
-
|
|
2171
|
-
|
|
2222
|
+
const hasDisplayOptions =
|
|
2223
|
+
paginated ||
|
|
2224
|
+
(children === undefined &&
|
|
2225
|
+
activeView === "table" &&
|
|
2226
|
+
(sortableFields.length > 0 ||
|
|
2227
|
+
availableGroupingFields.length > 0 ||
|
|
2228
|
+
pickerColumns.length > 0));
|
|
2229
|
+
const displayLabel = "Opções da lista";
|
|
2172
2230
|
|
|
2173
2231
|
// — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
|
|
2174
2232
|
// elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
|
|
@@ -2215,240 +2273,259 @@ export function ActionList<TInput, TItem>({
|
|
|
2215
2273
|
filterIcons={filterIcons}
|
|
2216
2274
|
advancedFilters={advancedFilters}
|
|
2217
2275
|
periodPosition={periodPosition}
|
|
2276
|
+
periodControl={periodControl}
|
|
2218
2277
|
period={period}
|
|
2219
|
-
onRefresh={() => refetch()}
|
|
2220
|
-
refreshing={isLoading}
|
|
2221
2278
|
controls={
|
|
2222
2279
|
<>
|
|
2223
|
-
{/*
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
}
|
|
2273
|
-
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2274
|
-
>
|
|
2275
|
-
<Eraser className="size-3" />
|
|
2276
|
-
Padrão
|
|
2277
|
-
</button>
|
|
2278
|
-
)}
|
|
2279
|
-
</div>
|
|
2280
|
-
<Select
|
|
2281
|
-
aria-label="Ordenar por"
|
|
2282
|
-
size="sm"
|
|
2283
|
-
className="w-full"
|
|
2284
|
-
value={activeSort?.field ?? "__none__"}
|
|
2285
|
-
onChange={(field) =>
|
|
2286
|
-
setState({
|
|
2287
|
-
...state,
|
|
2288
|
-
sort:
|
|
2289
|
-
field === "__none__"
|
|
2290
|
-
? null
|
|
2291
|
-
: {
|
|
2292
|
-
field,
|
|
2293
|
-
dir:
|
|
2294
|
-
activeSort?.field === field
|
|
2295
|
-
? activeSort.dir
|
|
2296
|
-
: "asc",
|
|
2297
|
-
},
|
|
2298
|
-
page: 1,
|
|
2299
|
-
})
|
|
2300
|
-
}
|
|
2301
|
-
options={[
|
|
2302
|
-
...(defaultSort === null
|
|
2303
|
-
? [
|
|
2304
|
-
{
|
|
2305
|
-
value: "__none__",
|
|
2306
|
-
label: "Sem ordenação",
|
|
2307
|
-
},
|
|
2308
|
-
]
|
|
2309
|
-
: []),
|
|
2310
|
-
...sortableFields,
|
|
2311
|
-
]}
|
|
2312
|
-
trailing={
|
|
2313
|
-
activeSort !== null ? (
|
|
2314
|
-
<Button
|
|
2280
|
+
{/* Opções da lista: preferências disponíveis para a visualização. */}
|
|
2281
|
+
{hasDisplayOptions && (
|
|
2282
|
+
<Popover>
|
|
2283
|
+
{/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
|
|
2284
|
+
<Tooltip>
|
|
2285
|
+
<TooltipTrigger asChild>
|
|
2286
|
+
<PopoverTrigger asChild>
|
|
2287
|
+
<Button
|
|
2288
|
+
variant="ghost"
|
|
2289
|
+
size="icon"
|
|
2290
|
+
className="relative shrink-0"
|
|
2291
|
+
aria-label={displayLabel}
|
|
2292
|
+
>
|
|
2293
|
+
<Settings2 className="h-3.5 w-3.5" />
|
|
2294
|
+
{displayCustomized && (
|
|
2295
|
+
<Dot
|
|
2296
|
+
context="primary"
|
|
2297
|
+
className="absolute top-1 right-1 ring-2 ring-background"
|
|
2298
|
+
/>
|
|
2299
|
+
)}
|
|
2300
|
+
</Button>
|
|
2301
|
+
</PopoverTrigger>
|
|
2302
|
+
</TooltipTrigger>
|
|
2303
|
+
<TooltipContent>{displayLabel}</TooltipContent>
|
|
2304
|
+
</Tooltip>
|
|
2305
|
+
<PopoverContent
|
|
2306
|
+
ref={optionsPanelRef}
|
|
2307
|
+
tabIndex={-1}
|
|
2308
|
+
onOpenAutoFocus={(event) => {
|
|
2309
|
+
// O Select abre ao receber foco; entrar no painel não deve abrir suas opções.
|
|
2310
|
+
event.preventDefault();
|
|
2311
|
+
optionsPanelRef.current?.focus();
|
|
2312
|
+
}}
|
|
2313
|
+
align="end"
|
|
2314
|
+
className="w-60 p-3"
|
|
2315
|
+
data-slot="action-list-display"
|
|
2316
|
+
>
|
|
2317
|
+
<div className="space-y-3">
|
|
2318
|
+
{children === undefined &&
|
|
2319
|
+
activeView === "table" &&
|
|
2320
|
+
sortableFields.length > 0 && (
|
|
2321
|
+
<>
|
|
2322
|
+
<div className="space-y-2">
|
|
2323
|
+
<div className="flex items-center justify-between">
|
|
2324
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2325
|
+
Ordenação
|
|
2326
|
+
</span>
|
|
2327
|
+
{sortCustomized && (
|
|
2328
|
+
<button
|
|
2315
2329
|
type="button"
|
|
2316
|
-
variant="ghost"
|
|
2317
|
-
size="icon-xs"
|
|
2318
|
-
aria-label={
|
|
2319
|
-
activeSort.dir === "asc"
|
|
2320
|
-
? "Ordem crescente. Alterar para decrescente"
|
|
2321
|
-
: "Ordem decrescente. Alterar para crescente"
|
|
2322
|
-
}
|
|
2323
2330
|
onClick={() =>
|
|
2324
2331
|
setState({
|
|
2325
2332
|
...state,
|
|
2326
|
-
sort:
|
|
2327
|
-
...activeSort,
|
|
2328
|
-
dir:
|
|
2329
|
-
activeSort.dir === "asc"
|
|
2330
|
-
? "desc"
|
|
2331
|
-
: "asc",
|
|
2332
|
-
},
|
|
2333
|
+
sort: defaultSort,
|
|
2333
2334
|
page: 1,
|
|
2334
2335
|
})
|
|
2335
2336
|
}
|
|
2337
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2336
2338
|
>
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2339
|
+
<Eraser className="size-3" />
|
|
2340
|
+
Padrão
|
|
2341
|
+
</button>
|
|
2342
|
+
)}
|
|
2343
|
+
</div>
|
|
2344
|
+
<Select
|
|
2345
|
+
aria-label="Ordenar por"
|
|
2346
|
+
size="sm"
|
|
2347
|
+
className="w-full"
|
|
2348
|
+
value={activeSort?.field ?? "__none__"}
|
|
2349
|
+
onChange={(field) =>
|
|
2350
|
+
setState({
|
|
2351
|
+
...state,
|
|
2352
|
+
sort:
|
|
2353
|
+
field === "__none__"
|
|
2354
|
+
? null
|
|
2355
|
+
: {
|
|
2356
|
+
field,
|
|
2357
|
+
dir:
|
|
2358
|
+
activeSort?.field === field
|
|
2359
|
+
? activeSort.dir
|
|
2360
|
+
: "asc",
|
|
2361
|
+
},
|
|
2362
|
+
page: 1,
|
|
2363
|
+
})
|
|
2364
|
+
}
|
|
2365
|
+
options={[
|
|
2366
|
+
...(defaultSort === null
|
|
2367
|
+
? [
|
|
2368
|
+
{
|
|
2369
|
+
value: "__none__",
|
|
2370
|
+
label: "Sem ordenação",
|
|
2371
|
+
},
|
|
2372
|
+
]
|
|
2373
|
+
: []),
|
|
2374
|
+
...sortableFields,
|
|
2375
|
+
]}
|
|
2376
|
+
trailing={
|
|
2377
|
+
activeSort !== null ? (
|
|
2378
|
+
<Button
|
|
2379
|
+
type="button"
|
|
2380
|
+
variant="ghost"
|
|
2381
|
+
size="icon-xs"
|
|
2382
|
+
aria-label={
|
|
2383
|
+
activeSort.dir === "asc"
|
|
2384
|
+
? "Ordem crescente. Alterar para decrescente"
|
|
2385
|
+
: "Ordem decrescente. Alterar para crescente"
|
|
2386
|
+
}
|
|
2387
|
+
onClick={() =>
|
|
2388
|
+
setState({
|
|
2389
|
+
...state,
|
|
2390
|
+
sort: {
|
|
2391
|
+
...activeSort,
|
|
2392
|
+
dir:
|
|
2393
|
+
activeSort.dir === "asc"
|
|
2394
|
+
? "desc"
|
|
2395
|
+
: "asc",
|
|
2396
|
+
},
|
|
2397
|
+
page: 1,
|
|
2398
|
+
})
|
|
2399
|
+
}
|
|
2400
|
+
>
|
|
2401
|
+
{activeSort.dir === "asc" ? (
|
|
2402
|
+
<ArrowUp aria-hidden="true" />
|
|
2403
|
+
) : (
|
|
2404
|
+
<ArrowDown aria-hidden="true" />
|
|
2405
|
+
)}
|
|
2406
|
+
</Button>
|
|
2407
|
+
) : undefined
|
|
2408
|
+
}
|
|
2409
|
+
/>
|
|
2410
|
+
</div>
|
|
2411
|
+
<Separator className="last:hidden" />
|
|
2412
|
+
</>
|
|
2413
|
+
)}
|
|
2414
|
+
{children === undefined &&
|
|
2415
|
+
activeView === "table" &&
|
|
2416
|
+
availableGroupingFields.length > 0 && (
|
|
2417
|
+
<>
|
|
2418
|
+
<div className="space-y-2">
|
|
2419
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2420
|
+
Agrupar por
|
|
2421
|
+
</span>
|
|
2422
|
+
<Select
|
|
2423
|
+
aria-label="Agrupar por"
|
|
2424
|
+
size="sm"
|
|
2425
|
+
className="w-full"
|
|
2426
|
+
value={activeGroupBy ?? "__none__"}
|
|
2427
|
+
onChange={(value) =>
|
|
2428
|
+
setState({
|
|
2429
|
+
...state,
|
|
2430
|
+
groupBy: value === "__none__" ? null : value,
|
|
2431
|
+
page: 1,
|
|
2432
|
+
})
|
|
2433
|
+
}
|
|
2434
|
+
options={[
|
|
2435
|
+
{ value: "__none__", label: "Sem agrupamento" },
|
|
2436
|
+
...availableGroupingFields.map((field) => ({
|
|
2437
|
+
value: field,
|
|
2438
|
+
label: text(
|
|
2439
|
+
action.filters?.[field]?.label,
|
|
2440
|
+
field,
|
|
2441
|
+
),
|
|
2442
|
+
})),
|
|
2443
|
+
]}
|
|
2444
|
+
/>
|
|
2445
|
+
</div>
|
|
2446
|
+
<Separator className="last:hidden" />
|
|
2447
|
+
</>
|
|
2448
|
+
)}
|
|
2449
|
+
{children === undefined &&
|
|
2450
|
+
activeView === "table" &&
|
|
2451
|
+
pickerColumns.length > 0 && (
|
|
2452
|
+
<>
|
|
2453
|
+
<div className="space-y-2">
|
|
2454
|
+
<div className="flex items-center justify-between">
|
|
2455
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
2456
|
+
Colunas
|
|
2457
|
+
</span>
|
|
2458
|
+
{state.columns !== null && (
|
|
2459
|
+
<button
|
|
2460
|
+
type="button"
|
|
2461
|
+
onClick={() =>
|
|
2462
|
+
setState({ ...state, columns: null })
|
|
2463
|
+
}
|
|
2464
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2465
|
+
>
|
|
2466
|
+
<Eraser className="size-3" />
|
|
2467
|
+
Padrão
|
|
2468
|
+
</button>
|
|
2469
|
+
)}
|
|
2470
|
+
</div>
|
|
2471
|
+
<div className="space-y-1.5">
|
|
2472
|
+
{pickerColumns.map((c) => (
|
|
2473
|
+
<label
|
|
2474
|
+
key={c.key}
|
|
2475
|
+
className="flex items-center gap-2 text-sm"
|
|
2476
|
+
>
|
|
2477
|
+
<Checkbox
|
|
2478
|
+
checked={(
|
|
2479
|
+
state.columns ?? defaultVisible
|
|
2480
|
+
).includes(c.key)}
|
|
2481
|
+
onCheckedChange={() => toggleColumn(c.key)}
|
|
2482
|
+
/>
|
|
2483
|
+
{text(c.label, c.key)}
|
|
2484
|
+
</label>
|
|
2485
|
+
))}
|
|
2486
|
+
</div>
|
|
2487
|
+
</div>
|
|
2488
|
+
<Separator className="last:hidden" />
|
|
2489
|
+
</>
|
|
2490
|
+
)}
|
|
2491
|
+
{paginated && (
|
|
2353
2492
|
<>
|
|
2354
2493
|
<div className="space-y-2">
|
|
2355
2494
|
<span className="text-xs font-medium text-muted-foreground">
|
|
2356
|
-
|
|
2495
|
+
{activeGroupBy === null
|
|
2496
|
+
? "Itens por página"
|
|
2497
|
+
: "Itens por grupo"}
|
|
2357
2498
|
</span>
|
|
2358
2499
|
<Select
|
|
2359
|
-
aria-label="Agrupar por"
|
|
2360
2500
|
size="sm"
|
|
2361
2501
|
className="w-full"
|
|
2362
|
-
value={
|
|
2363
|
-
onChange={(
|
|
2502
|
+
value={String(state.limit ?? pageSize)}
|
|
2503
|
+
onChange={(v) => {
|
|
2504
|
+
const n = Number(v);
|
|
2505
|
+
// O default do caller fica FORA da URL (limit null).
|
|
2364
2506
|
setState({
|
|
2365
2507
|
...state,
|
|
2366
|
-
|
|
2508
|
+
limit: n === pageSize ? null : n,
|
|
2367
2509
|
page: 1,
|
|
2368
|
-
})
|
|
2369
|
-
}
|
|
2370
|
-
options={
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
})),
|
|
2379
|
-
]}
|
|
2510
|
+
});
|
|
2511
|
+
}}
|
|
2512
|
+
options={Array.from(
|
|
2513
|
+
new Set([10, 25, 50, 100, pageSize]),
|
|
2514
|
+
)
|
|
2515
|
+
.sort((a, b) => a - b)
|
|
2516
|
+
.map((n) => ({
|
|
2517
|
+
value: String(n),
|
|
2518
|
+
label: String(n),
|
|
2519
|
+
}))}
|
|
2380
2520
|
/>
|
|
2381
2521
|
</div>
|
|
2382
|
-
<Separator />
|
|
2522
|
+
<Separator className="last:hidden" />
|
|
2383
2523
|
</>
|
|
2384
2524
|
)}
|
|
2385
|
-
{activeView === "table" && pickerColumns.length > 0 && (
|
|
2386
|
-
<>
|
|
2387
|
-
<div className="space-y-2">
|
|
2388
|
-
<div className="flex items-center justify-between">
|
|
2389
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
2390
|
-
Colunas
|
|
2391
|
-
</span>
|
|
2392
|
-
{state.columns !== null && (
|
|
2393
|
-
<button
|
|
2394
|
-
type="button"
|
|
2395
|
-
onClick={() =>
|
|
2396
|
-
setState({ ...state, columns: null })
|
|
2397
|
-
}
|
|
2398
|
-
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
2399
|
-
>
|
|
2400
|
-
<Eraser className="size-3" />
|
|
2401
|
-
Padrão
|
|
2402
|
-
</button>
|
|
2403
|
-
)}
|
|
2404
|
-
</div>
|
|
2405
|
-
<div className="space-y-1.5">
|
|
2406
|
-
{pickerColumns.map((c) => (
|
|
2407
|
-
<label
|
|
2408
|
-
key={c.key}
|
|
2409
|
-
className="flex items-center gap-2 text-sm"
|
|
2410
|
-
>
|
|
2411
|
-
<Checkbox
|
|
2412
|
-
checked={(
|
|
2413
|
-
state.columns ?? defaultVisible
|
|
2414
|
-
).includes(c.key)}
|
|
2415
|
-
onCheckedChange={() => toggleColumn(c.key)}
|
|
2416
|
-
/>
|
|
2417
|
-
{text(c.label, c.key)}
|
|
2418
|
-
</label>
|
|
2419
|
-
))}
|
|
2420
|
-
</div>
|
|
2421
|
-
</div>
|
|
2422
|
-
<Separator />
|
|
2423
|
-
</>
|
|
2424
|
-
)}
|
|
2425
|
-
<div className="space-y-2">
|
|
2426
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
2427
|
-
{activeGroupBy === null
|
|
2428
|
-
? "Itens por página"
|
|
2429
|
-
: "Itens por grupo"}
|
|
2430
|
-
</span>
|
|
2431
|
-
<Select
|
|
2432
|
-
size="sm"
|
|
2433
|
-
className="w-full"
|
|
2434
|
-
value={String(state.limit ?? pageSize)}
|
|
2435
|
-
onChange={(v) => {
|
|
2436
|
-
const n = Number(v);
|
|
2437
|
-
// O default do caller fica FORA da URL (limit null).
|
|
2438
|
-
setState({
|
|
2439
|
-
...state,
|
|
2440
|
-
limit: n === pageSize ? null : n,
|
|
2441
|
-
page: 1,
|
|
2442
|
-
});
|
|
2443
|
-
}}
|
|
2444
|
-
options={Array.from(new Set([10, 25, 50, 100, pageSize]))
|
|
2445
|
-
.sort((a, b) => a - b)
|
|
2446
|
-
.map((n) => ({ value: String(n), label: String(n) }))}
|
|
2447
|
-
/>
|
|
2448
2525
|
</div>
|
|
2449
|
-
</
|
|
2450
|
-
</
|
|
2451
|
-
|
|
2526
|
+
</PopoverContent>
|
|
2527
|
+
</Popover>
|
|
2528
|
+
)}
|
|
2452
2529
|
{hasSegment && (
|
|
2453
2530
|
<ToggleGroup
|
|
2454
2531
|
type="single"
|
|
@@ -2481,7 +2558,7 @@ export function ActionList<TInput, TItem>({
|
|
|
2481
2558
|
{viewControls}
|
|
2482
2559
|
</>
|
|
2483
2560
|
}
|
|
2484
|
-
actions={
|
|
2561
|
+
actions={title === undefined ? collectionActions : undefined}
|
|
2485
2562
|
/>
|
|
2486
2563
|
</div>
|
|
2487
2564
|
) : null;
|
|
@@ -2492,7 +2569,10 @@ export function ActionList<TInput, TItem>({
|
|
|
2492
2569
|
const initialLoading =
|
|
2493
2570
|
busy && items.length === 0 && !(empty !== undefined && !isEmpty);
|
|
2494
2571
|
const tableFillsAvailableHeight =
|
|
2495
|
-
layout === "fill" &&
|
|
2572
|
+
layout === "fill" &&
|
|
2573
|
+
itemSpec === undefined &&
|
|
2574
|
+
children === undefined &&
|
|
2575
|
+
activeView === "table";
|
|
2496
2576
|
const tableStateActive =
|
|
2497
2577
|
tableFillsAvailableHeight && (initialLoading || isError || isEmpty);
|
|
2498
2578
|
const tableColumnCount = Math.max(
|
|
@@ -2549,14 +2629,56 @@ export function ActionList<TInput, TItem>({
|
|
|
2549
2629
|
className="w-px whitespace-nowrap text-right"
|
|
2550
2630
|
onClick={(e) => e.stopPropagation()}
|
|
2551
2631
|
>
|
|
2552
|
-
<RowActions>{rowActions(item)}</RowActions>
|
|
2632
|
+
<RowActions>{rowActions(item, listContext)}</RowActions>
|
|
2553
2633
|
</TableCell>
|
|
2554
2634
|
)}
|
|
2555
2635
|
</TableRow>
|
|
2556
2636
|
));
|
|
2557
|
-
const tableRows = renderTableRows(items);
|
|
2637
|
+
const tableRows = itemSpec === undefined ? renderTableRows(items) : null;
|
|
2558
2638
|
let content: ReactNode;
|
|
2559
|
-
if (
|
|
2639
|
+
if (itemSpec !== undefined) {
|
|
2640
|
+
const fieldValue = (entry: TItem, field: string): ReactNode => {
|
|
2641
|
+
const value =
|
|
2642
|
+
entry !== null &&
|
|
2643
|
+
typeof entry === "object" &&
|
|
2644
|
+
Object.prototype.hasOwnProperty.call(entry, field)
|
|
2645
|
+
? (entry as Record<string, unknown>)[field]
|
|
2646
|
+
: undefined;
|
|
2647
|
+
return typeof value === "string" || typeof value === "number"
|
|
2648
|
+
? value
|
|
2649
|
+
: null;
|
|
2650
|
+
};
|
|
2651
|
+
content = (
|
|
2652
|
+
<ItemGroup variant="framed">
|
|
2653
|
+
{items.map((entry, index) => {
|
|
2654
|
+
const description =
|
|
2655
|
+
itemSpec.description === undefined
|
|
2656
|
+
? null
|
|
2657
|
+
: fieldValue(entry, itemSpec.description);
|
|
2658
|
+
return (
|
|
2659
|
+
<Fragment key={rowId(entry)}>
|
|
2660
|
+
{index > 0 && <ItemSeparator />}
|
|
2661
|
+
<Item>
|
|
2662
|
+
<ItemHeader>
|
|
2663
|
+
<ItemTitle className="block w-full truncate">
|
|
2664
|
+
{fieldValue(entry, itemSpec.title) ?? <EmptyValue />}
|
|
2665
|
+
</ItemTitle>
|
|
2666
|
+
{description !== null && description !== "" && (
|
|
2667
|
+
<ItemDescription className="line-clamp-1 text-nowrap">
|
|
2668
|
+
{description}
|
|
2669
|
+
</ItemDescription>
|
|
2670
|
+
)}
|
|
2671
|
+
</ItemHeader>
|
|
2672
|
+
{rowActions !== undefined && (
|
|
2673
|
+
<ItemActions>{rowActions(entry, listContext)}</ItemActions>
|
|
2674
|
+
)}
|
|
2675
|
+
</Item>
|
|
2676
|
+
</Fragment>
|
|
2677
|
+
);
|
|
2678
|
+
})}
|
|
2679
|
+
</ItemGroup>
|
|
2680
|
+
);
|
|
2681
|
+
} else if (children !== undefined) {
|
|
2560
2682
|
// Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
|
|
2561
2683
|
content = children(items, () => refetch());
|
|
2562
2684
|
} else if (activeView !== "table" && views?.[activeView] !== undefined) {
|
|
@@ -2887,95 +3009,126 @@ export function ActionList<TInput, TItem>({
|
|
|
2887
3009
|
)}
|
|
2888
3010
|
</span>
|
|
2889
3011
|
) : null;
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
<PaginationItem key={n}>
|
|
2934
|
-
<PaginationLink
|
|
2935
|
-
page={n}
|
|
2936
|
-
isActive={n === state.page}
|
|
2937
|
-
size="sm"
|
|
2938
|
-
className="text-xs"
|
|
2939
|
-
onClick={() => goTo(n)}
|
|
2940
|
-
/>
|
|
2941
|
-
</PaginationItem>
|
|
2942
|
-
),
|
|
2943
|
-
)}
|
|
2944
|
-
<PaginationItem>
|
|
2945
|
-
<PaginationNext
|
|
2946
|
-
iconOnly
|
|
2947
|
-
size="icon-sm"
|
|
2948
|
-
disabled={state.page >= pages}
|
|
2949
|
-
onClick={() => goTo(state.page + 1)}
|
|
2950
|
-
/>
|
|
2951
|
-
</PaginationItem>
|
|
2952
|
-
</PaginationContent>
|
|
2953
|
-
</Pagination>
|
|
2954
|
-
)}
|
|
2955
|
-
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
2956
|
-
{pages <= 1 && <span />}
|
|
2957
|
-
<span className="justify-self-end whitespace-nowrap">
|
|
2958
|
-
{total} {total === 1 ? "item" : "itens"} no total
|
|
3012
|
+
useEffect(() => {
|
|
3013
|
+
if (
|
|
3014
|
+
paginated &&
|
|
3015
|
+
itemSpec !== undefined &&
|
|
3016
|
+
!isLoading &&
|
|
3017
|
+
!isFetching &&
|
|
3018
|
+
!isError &&
|
|
3019
|
+
total !== undefined &&
|
|
3020
|
+
state.page > pages
|
|
3021
|
+
)
|
|
3022
|
+
setState({ ...state, page: pages });
|
|
3023
|
+
}, [
|
|
3024
|
+
paginated,
|
|
3025
|
+
itemSpec,
|
|
3026
|
+
isLoading,
|
|
3027
|
+
isFetching,
|
|
3028
|
+
isError,
|
|
3029
|
+
total,
|
|
3030
|
+
state.page,
|
|
3031
|
+
pages,
|
|
3032
|
+
]);
|
|
3033
|
+
// O piso soma os controles e o padding em rem; a compensação fixa cobre apenas a borda superior.
|
|
3034
|
+
const footer = !paginated || total === 0 ? null : itemSpec !== undefined &&
|
|
3035
|
+
pages <= 1 ? null : total !== undefined &&
|
|
3036
|
+
!isError &&
|
|
3037
|
+
activeGroupBy !== null ? (
|
|
3038
|
+
<div
|
|
3039
|
+
data-slot="action-list-footer"
|
|
3040
|
+
className="flex min-h-[calc(3.5rem+1px)] flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-3 text-xs text-muted-foreground"
|
|
3041
|
+
>
|
|
3042
|
+
{displaySummary}
|
|
3043
|
+
<span className={cn(displaySummary === null && "ml-auto")}>
|
|
3044
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
3045
|
+
</span>
|
|
3046
|
+
</div>
|
|
3047
|
+
) : total !== undefined && !isError ? (
|
|
3048
|
+
<div
|
|
3049
|
+
data-slot="action-list-footer"
|
|
3050
|
+
className="grid min-h-[calc(3.5rem+1px)] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-3 text-xs text-muted-foreground"
|
|
3051
|
+
>
|
|
3052
|
+
<span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
3053
|
+
<span className="whitespace-nowrap">
|
|
3054
|
+
Página {state.page} de {pages}
|
|
2959
3055
|
</span>
|
|
2960
|
-
|
|
2961
|
-
|
|
3056
|
+
{displaySummary !== null && <span aria-hidden="true">·</span>}
|
|
3057
|
+
{displaySummary}
|
|
3058
|
+
</span>
|
|
3059
|
+
{pages > 1 && (
|
|
3060
|
+
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
3061
|
+
// números `sm` que crescem com os dígitos (5726 e 5727 não se colam).
|
|
3062
|
+
<Pagination className="mx-0 w-auto">
|
|
3063
|
+
<PaginationContent className="gap-0.5">
|
|
3064
|
+
<PaginationItem>
|
|
3065
|
+
<PaginationPrevious
|
|
3066
|
+
iconOnly
|
|
3067
|
+
size="icon-sm"
|
|
3068
|
+
disabled={state.page <= 1}
|
|
3069
|
+
onClick={() => goTo(state.page - 1)}
|
|
3070
|
+
/>
|
|
3071
|
+
</PaginationItem>
|
|
3072
|
+
{pageWindow(state.page, pages).map((n, i) =>
|
|
3073
|
+
n === "…" ? (
|
|
3074
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
3075
|
+
<PaginationItem key={`e${i}`}>
|
|
3076
|
+
<PaginationEllipsis size="icon-sm" />
|
|
3077
|
+
</PaginationItem>
|
|
3078
|
+
) : (
|
|
3079
|
+
<PaginationItem key={n}>
|
|
3080
|
+
<PaginationLink
|
|
3081
|
+
page={n}
|
|
3082
|
+
isActive={n === state.page}
|
|
3083
|
+
size="sm"
|
|
3084
|
+
className="text-xs"
|
|
3085
|
+
onClick={() => goTo(n)}
|
|
3086
|
+
/>
|
|
3087
|
+
</PaginationItem>
|
|
3088
|
+
),
|
|
3089
|
+
)}
|
|
3090
|
+
<PaginationItem>
|
|
3091
|
+
<PaginationNext
|
|
3092
|
+
iconOnly
|
|
3093
|
+
size="icon-sm"
|
|
3094
|
+
disabled={state.page >= pages}
|
|
3095
|
+
onClick={() => goTo(state.page + 1)}
|
|
3096
|
+
/>
|
|
3097
|
+
</PaginationItem>
|
|
3098
|
+
</PaginationContent>
|
|
3099
|
+
</Pagination>
|
|
3100
|
+
)}
|
|
3101
|
+
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
3102
|
+
{pages <= 1 && <span />}
|
|
3103
|
+
<span className="justify-self-end whitespace-nowrap">
|
|
3104
|
+
{total} {total === 1 ? "item" : "itens"} no total
|
|
3105
|
+
</span>
|
|
3106
|
+
</div>
|
|
3107
|
+
) : null;
|
|
2962
3108
|
|
|
2963
3109
|
return (
|
|
2964
3110
|
<TooltipProvider>
|
|
2965
3111
|
<div
|
|
2966
3112
|
ref={rootRef}
|
|
2967
|
-
data-
|
|
3113
|
+
data-slot="action-list"
|
|
3114
|
+
data-action={actionProp?.name}
|
|
3115
|
+
data-source={source === undefined ? undefined : "external"}
|
|
2968
3116
|
data-layout={layout}
|
|
2969
3117
|
className={cn(
|
|
2970
3118
|
layout === "fill"
|
|
2971
|
-
? "flex h-full min-h-0 min-w-0 max-w-full flex-1 flex-col gap-
|
|
2972
|
-
: "min-w-0 max-w-full space-y-
|
|
3119
|
+
? "flex h-full min-h-0 min-w-0 max-w-full flex-1 flex-col gap-4"
|
|
3120
|
+
: "min-w-0 max-w-full space-y-4",
|
|
2973
3121
|
)}
|
|
2974
3122
|
>
|
|
2975
3123
|
{/* A lista é a folha da cadeia de altura: o que ela hospeda decide a própria. */}
|
|
2976
3124
|
<FillBoundary>
|
|
3125
|
+
{title !== undefined && (
|
|
3126
|
+
<Surface title={title} actions={collectionActions} />
|
|
3127
|
+
)}
|
|
2977
3128
|
{toolbar}
|
|
2978
|
-
{beforeContent
|
|
3129
|
+
{typeof beforeContent === "function"
|
|
3130
|
+
? beforeContent(effectiveInput)
|
|
3131
|
+
: beforeContent}
|
|
2979
3132
|
{selectionBar}
|
|
2980
3133
|
{/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
|
|
2981
3134
|
ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
|
|
@@ -3120,12 +3273,87 @@ export function listParamsToState(
|
|
|
3120
3273
|
to: range?.[2] ?? params.get("to"),
|
|
3121
3274
|
view: params.get("view"),
|
|
3122
3275
|
columns: cols !== null && cols !== "" ? cols.split(",") : null,
|
|
3123
|
-
limit:
|
|
3124
|
-
|
|
3276
|
+
limit:
|
|
3277
|
+
action.paginate !== false &&
|
|
3278
|
+
Number.isInteger(limitParam) &&
|
|
3279
|
+
limitParam > 0
|
|
3280
|
+
? limitParam
|
|
3281
|
+
: null,
|
|
3282
|
+
page:
|
|
3283
|
+
action.paginate !== false && Number.isInteger(page) && page > 1
|
|
3284
|
+
? page
|
|
3285
|
+
: 1,
|
|
3125
3286
|
groupBy,
|
|
3126
3287
|
};
|
|
3127
3288
|
}
|
|
3128
3289
|
|
|
3290
|
+
/** Materializa o mesmo recorte da coleção para consulta e hidratação de detalhes roteados. */
|
|
3291
|
+
export function listStateToInput(
|
|
3292
|
+
state: ActionListState,
|
|
3293
|
+
action: ListActionLike,
|
|
3294
|
+
{
|
|
3295
|
+
input = {},
|
|
3296
|
+
filterDefaults,
|
|
3297
|
+
pageSize = 50,
|
|
3298
|
+
applyPeriod = true,
|
|
3299
|
+
}: {
|
|
3300
|
+
input?: Record<string, unknown>;
|
|
3301
|
+
filterDefaults?: Record<string, string>;
|
|
3302
|
+
pageSize?: number;
|
|
3303
|
+
/** False quando o consumidor fornece integralmente o intervalo temporal em input. */
|
|
3304
|
+
applyPeriod?: boolean;
|
|
3305
|
+
} = {},
|
|
3306
|
+
): Record<string, unknown> {
|
|
3307
|
+
const sort = state.sort ?? action.sort?.default?.[0] ?? null;
|
|
3308
|
+
const filled: Record<string, unknown> = {};
|
|
3309
|
+
for (const [name, v] of Object.entries({
|
|
3310
|
+
...filterDefaults,
|
|
3311
|
+
...state.filters,
|
|
3312
|
+
})) {
|
|
3313
|
+
if (isEmptyValue(v)) continue;
|
|
3314
|
+
const filter = action.filters?.[name];
|
|
3315
|
+
if (
|
|
3316
|
+
filter?.type === "date" &&
|
|
3317
|
+
!isValidDateFilterValue(v, filter.multiple === true)
|
|
3318
|
+
)
|
|
3319
|
+
continue;
|
|
3320
|
+
filled[name] = v;
|
|
3321
|
+
}
|
|
3322
|
+
// Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
|
|
3323
|
+
// range. state.period null = o default do contrato (sempre aplicado).
|
|
3324
|
+
const effectivePeriod = applyPeriod
|
|
3325
|
+
? (state.period ??
|
|
3326
|
+
action.periods?.find((p) => p.default === true)?.value ??
|
|
3327
|
+
action.periods?.[0]?.value ??
|
|
3328
|
+
null)
|
|
3329
|
+
: null;
|
|
3330
|
+
let range: { from: string; to: string } | null = null;
|
|
3331
|
+
if (
|
|
3332
|
+
effectivePeriod === "custom" &&
|
|
3333
|
+
state.from !== null &&
|
|
3334
|
+
state.to !== null
|
|
3335
|
+
) {
|
|
3336
|
+
range = { from: state.from, to: state.to };
|
|
3337
|
+
} else if (effectivePeriod !== null && effectivePeriod !== "custom") {
|
|
3338
|
+
range = presetRange(effectivePeriod);
|
|
3339
|
+
}
|
|
3340
|
+
const result: Record<string, unknown> = {
|
|
3341
|
+
...input,
|
|
3342
|
+
...filled,
|
|
3343
|
+
...(range !== null ? range : {}),
|
|
3344
|
+
...(state.q.trim() !== "" ? { q: state.q.trim() } : {}),
|
|
3345
|
+
...(sort !== null ? { sort: `${sort.field}:${sort.dir}` } : {}),
|
|
3346
|
+
// Paginação server-driven: o handler implementa OFFSET e devolve `total`.
|
|
3347
|
+
limit: state.limit ?? pageSize,
|
|
3348
|
+
page: state.page,
|
|
3349
|
+
};
|
|
3350
|
+
if (action.paginate === false) {
|
|
3351
|
+
delete result.limit;
|
|
3352
|
+
delete result.page;
|
|
3353
|
+
}
|
|
3354
|
+
return result;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3129
3357
|
/** Estado da toolbar → querystring ('' ou '?…'), omitindo defaults (URL limpa). */
|
|
3130
3358
|
export function listStateToParams(
|
|
3131
3359
|
state: ActionListState,
|
|
@@ -3164,8 +3392,10 @@ export function listStateToParams(
|
|
|
3164
3392
|
}
|
|
3165
3393
|
if (state.view !== null) qs.set("view", state.view);
|
|
3166
3394
|
if (state.columns !== null) qs.set("cols", state.columns.join(","));
|
|
3167
|
-
if (
|
|
3168
|
-
|
|
3395
|
+
if (action.paginate !== false && state.limit !== null)
|
|
3396
|
+
qs.set("limit", String(state.limit));
|
|
3397
|
+
if (action.paginate !== false && state.page > 1)
|
|
3398
|
+
qs.set("page", String(state.page));
|
|
3169
3399
|
if (
|
|
3170
3400
|
state.groupBy !== undefined &&
|
|
3171
3401
|
state.groupBy !== null &&
|