@asteby/metacore-runtime-react 18.9.0 → 18.10.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 +12 -0
- package/dist/dynamic-columns.d.ts +15 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +36 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +44 -6
- package/package.json +4 -4
- package/src/dynamic-columns.tsx +50 -0
- package/src/dynamic-table.tsx +72 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 35289f7: feat(dynamic-table): table footer totals — a declarative per-column SUM over the FILTERED set
|
|
8
|
+
|
|
9
|
+
A column opts into a footer total via its manifest `display_config.aggregate: "sum"` (mapped by the kernel to `styleConfig.aggregate` at runtime). `DynamicTable` now fetches those totals from a separate `${endpoint}/aggregate` endpoint that reuses the SAME filter/search params as the list (no sort, no pagination) — so the footer reflects the whole filtered set, not the visible page — and refetches whenever filters/search change.
|
|
10
|
+
|
|
11
|
+
`<TableFooter>` renders one cell per visible column: aggregate-flagged columns show the total formatted with the SAME helpers the body cells use (currency columns → org currency via `resolveCurrency` + `formatNumber`, number columns honour `styleConfig.decimals`), every other column gets an empty cell, and the first column carries a "Total" label. The footer only renders when at least one column opts in and totals are present.
|
|
12
|
+
|
|
13
|
+
New exports from `dynamic-columns`: `aggregateOf(col)` and `formatAggregateTotal(col, value, currency, locale)`.
|
|
14
|
+
|
|
3
15
|
## 18.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -22,6 +22,21 @@ export interface DynamicColumnsHelpers {
|
|
|
22
22
|
* 'USD' as a last resort.
|
|
23
23
|
*/
|
|
24
24
|
export declare const resolveCurrency: (col: ColumnDefinition, orgCurrency?: string) => string;
|
|
25
|
+
/**
|
|
26
|
+
* Reads the column's footer-aggregate opt-in. A column opts into the table
|
|
27
|
+
* footer total via its manifest `display_config.aggregate` (mapped by the
|
|
28
|
+
* kernel to `styleConfig.aggregate` at runtime). Returns the aggregate kind
|
|
29
|
+
* (e.g. `'sum'`) or undefined when the column carries no footer total.
|
|
30
|
+
*/
|
|
31
|
+
export declare const aggregateOf: (col: ColumnDefinition) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Formats a footer aggregate total with the SAME rules the body cells use:
|
|
34
|
+
* currency columns render as the org currency (resolveCurrency), number
|
|
35
|
+
* columns honour `styleConfig.decimals`, everything else falls back to a
|
|
36
|
+
* locale-formatted number. Non-numeric/empty totals render as a dash so an
|
|
37
|
+
* empty filtered set reads cleanly.
|
|
38
|
+
*/
|
|
39
|
+
export declare const formatAggregateTotal: (col: ColumnDefinition, value: unknown, currency?: string, locale?: string) => string;
|
|
25
40
|
/**
|
|
26
41
|
* State-machine gate for per-row actions.
|
|
27
42
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAqKD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAsID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAinBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -51,6 +51,42 @@ const EmptyCell = () => _jsx("span", { className: "text-muted-foreground", child
|
|
|
51
51
|
*/
|
|
52
52
|
export const resolveCurrency = (col, orgCurrency) => styleCfg(col, 'currency') || orgCurrency || 'USD';
|
|
53
53
|
const formatNumber = (value, opts, locale) => new Intl.NumberFormat(locale || undefined, opts).format(value);
|
|
54
|
+
/**
|
|
55
|
+
* Reads the column's footer-aggregate opt-in. A column opts into the table
|
|
56
|
+
* footer total via its manifest `display_config.aggregate` (mapped by the
|
|
57
|
+
* kernel to `styleConfig.aggregate` at runtime). Returns the aggregate kind
|
|
58
|
+
* (e.g. `'sum'`) or undefined when the column carries no footer total.
|
|
59
|
+
*/
|
|
60
|
+
export const aggregateOf = (col) => {
|
|
61
|
+
const v = styleCfg(col, 'aggregate');
|
|
62
|
+
return typeof v === 'string' && v !== '' ? v : undefined;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Formats a footer aggregate total with the SAME rules the body cells use:
|
|
66
|
+
* currency columns render as the org currency (resolveCurrency), number
|
|
67
|
+
* columns honour `styleConfig.decimals`, everything else falls back to a
|
|
68
|
+
* locale-formatted number. Non-numeric/empty totals render as a dash so an
|
|
69
|
+
* empty filtered set reads cleanly.
|
|
70
|
+
*/
|
|
71
|
+
export const formatAggregateTotal = (col, value, currency, locale) => {
|
|
72
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
73
|
+
if (value === null || value === undefined || isNaN(num))
|
|
74
|
+
return '—';
|
|
75
|
+
const renderAs = col.cellStyle ?? col.type;
|
|
76
|
+
if (renderAs === 'currency') {
|
|
77
|
+
const decimals = styleCfg(col, 'decimals') ?? 2;
|
|
78
|
+
return formatNumber(num, {
|
|
79
|
+
style: 'currency',
|
|
80
|
+
currency: resolveCurrency(col, currency),
|
|
81
|
+
minimumFractionDigits: decimals,
|
|
82
|
+
maximumFractionDigits: decimals,
|
|
83
|
+
}, locale);
|
|
84
|
+
}
|
|
85
|
+
const decimals = styleCfg(col, 'decimals');
|
|
86
|
+
return formatNumber(num, decimals !== undefined
|
|
87
|
+
? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }
|
|
88
|
+
: {}, locale);
|
|
89
|
+
};
|
|
54
90
|
/**
|
|
55
91
|
* Semantic status → badge color. Used by the `status` cell when no explicit
|
|
56
92
|
* `options` color is declared. Generic, value-driven mapping.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAUnF,UAAU,iBAAiB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BAm2BnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -17,14 +17,14 @@ import { useTranslation } from 'react-i18next';
|
|
|
17
17
|
import { format } from 'date-fns';
|
|
18
18
|
import { flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table';
|
|
19
19
|
import { cn } from '@asteby/metacore-ui/lib';
|
|
20
|
-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Button, Skeleton, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@asteby/metacore-ui/primitives';
|
|
20
|
+
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Button, Skeleton, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@asteby/metacore-ui/primitives';
|
|
21
21
|
import { DataTablePagination, DataTableToolbar, DataTableBulkActions, } from '@asteby/metacore-ui/data-table';
|
|
22
22
|
import { Inbox, Download, Upload, Trash2 } from 'lucide-react';
|
|
23
23
|
import { toast } from 'sonner';
|
|
24
24
|
import { Progress } from './dialogs/_primitives';
|
|
25
25
|
import { useMetadataCache } from './metadata-cache';
|
|
26
26
|
import { useApi, useCurrentBranch } from './api-context';
|
|
27
|
-
import { defaultGetDynamicColumns, DATE_CELL_TYPES } from './dynamic-columns';
|
|
27
|
+
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
|
|
28
28
|
import { OptionsContext } from './options-context';
|
|
29
29
|
import { ActionModalDispatcher } from './action-modal-dispatcher';
|
|
30
30
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
@@ -41,6 +41,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
41
41
|
const cachedMeta = getMetadata(model);
|
|
42
42
|
const [metadata, setMetadata] = useState(cachedMeta || null);
|
|
43
43
|
const [data, setData] = useState([]);
|
|
44
|
+
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
45
|
+
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
46
|
+
const [footerTotals, setFooterTotals] = useState({});
|
|
44
47
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
45
48
|
const [loadingData, setLoadingData] = useState(true);
|
|
46
49
|
const [optionsMap, setOptionsMap] = useState(new Map());
|
|
@@ -345,6 +348,28 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
345
348
|
setLoadingData(false);
|
|
346
349
|
}
|
|
347
350
|
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api]);
|
|
351
|
+
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
352
|
+
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
353
|
+
// aggregate request is made.
|
|
354
|
+
const aggregateColumns = useMemo(() => (metadata?.columns ?? []).filter((c) => aggregateOf(c)), [metadata]);
|
|
355
|
+
// fetchAggregates GETs the SUM of each aggregate-flagged column over the SAME
|
|
356
|
+
// filtered set as the list (reuses buildFilterParams), then stores the totals
|
|
357
|
+
// keyed by column. Sort/pagination are irrelevant to a footer total and are
|
|
358
|
+
// omitted (the backend ignores them); only filters/search drive the result.
|
|
359
|
+
const fetchAggregates = useCallback(async () => {
|
|
360
|
+
if (!metadata || aggregateColumns.length === 0)
|
|
361
|
+
return;
|
|
362
|
+
try {
|
|
363
|
+
const { sortBy, order, ...filterParams } = buildFilterParams();
|
|
364
|
+
const base = endpoint || `/data/${model}`;
|
|
365
|
+
const res = (await api.get(`${base}/aggregate`, { params: filterParams }));
|
|
366
|
+
if (res.data.success)
|
|
367
|
+
setFooterTotals(res.data.data || {});
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
console.error('Error al cargar los totales', error);
|
|
371
|
+
}
|
|
372
|
+
}, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api]);
|
|
348
373
|
const initialFetchDone = useRef(false);
|
|
349
374
|
useEffect(() => {
|
|
350
375
|
if (!metadata)
|
|
@@ -352,12 +377,16 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
352
377
|
if (!initialFetchDone.current) {
|
|
353
378
|
initialFetchDone.current = true;
|
|
354
379
|
fetchData();
|
|
380
|
+
fetchAggregates();
|
|
355
381
|
return;
|
|
356
382
|
}
|
|
357
|
-
const timeoutId = setTimeout(
|
|
383
|
+
const timeoutId = setTimeout(() => {
|
|
384
|
+
fetchData();
|
|
385
|
+
fetchAggregates();
|
|
386
|
+
}, 300);
|
|
358
387
|
return () => clearTimeout(timeoutId);
|
|
359
|
-
}, [fetchData, metadata]);
|
|
360
|
-
const handleRefresh = useCallback(() => { fetchData(); }, [fetchData]);
|
|
388
|
+
}, [fetchData, fetchAggregates, metadata]);
|
|
389
|
+
const handleRefresh = useCallback(() => { fetchData(); fetchAggregates(); }, [fetchData, fetchAggregates]);
|
|
361
390
|
const handleInternalAction = useCallback(async (action, row) => {
|
|
362
391
|
if (action === 'delete') {
|
|
363
392
|
setRowToDelete(row);
|
|
@@ -597,7 +626,16 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
597
626
|
}) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', children: row.getVisibleCells().map((cell) => {
|
|
598
627
|
const isActionsColumn = cell.column.id === 'actions';
|
|
599
628
|
return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
|
|
600
|
-
}) }, row.id)))) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) })
|
|
629
|
+
}) }, row.id)))) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
|
|
630
|
+
const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
|
|
631
|
+
const isFirst = idx === 0;
|
|
632
|
+
// Aggregate cell: render the SUM formatted like the body cell.
|
|
633
|
+
if (col && aggregateOf(col)) {
|
|
634
|
+
return (_jsx(TableCell, { className: "py-2 text-right font-semibold tabular-nums", children: formatAggregateTotal(col, footerTotals[leaf.id], currency, i18n.language) }, leaf.id));
|
|
635
|
+
}
|
|
636
|
+
// First non-aggregate column carries the "Total" label.
|
|
637
|
+
return (_jsx(TableCell, { className: "py-2 font-semibold", children: isFirst ? t('common.total', 'Total') : '' }, leaf.id));
|
|
638
|
+
}) }) }))] }) }), _jsx("div", { className: 'flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden', children: loadingData && data.length === 0 ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("div", { className: 'rounded-lg border bg-card p-3', children: [_jsx(Skeleton, { className: 'h-4 w-24' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-40' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-32' })] }, i)))) : table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => {
|
|
601
639
|
const cells = row.getVisibleCells();
|
|
602
640
|
const actionsCell = cells.find((c) => c.column.id === 'actions');
|
|
603
641
|
const dataCells = cells.filter((c) => c.column.id !== 'actions' && c.column.id !== 'select');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.10.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"date-fns": ">=3",
|
|
35
35
|
"react-day-picker": ">=8",
|
|
36
36
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
37
|
-
"@asteby/metacore-ui": "^2.5.
|
|
37
|
+
"@asteby/metacore-ui": "^2.5.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@tanstack/react-router": {
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"typescript": "^6.0.0",
|
|
62
62
|
"vitest": "^4.0.0",
|
|
63
63
|
"zustand": "^5.0.0",
|
|
64
|
-
"@asteby/metacore-
|
|
65
|
-
"@asteby/metacore-
|
|
64
|
+
"@asteby/metacore-sdk": "3.2.0",
|
|
65
|
+
"@asteby/metacore-ui": "2.5.1"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc -p tsconfig.json",
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -107,6 +107,56 @@ const formatNumber = (
|
|
|
107
107
|
locale?: string,
|
|
108
108
|
) => new Intl.NumberFormat(locale || undefined, opts).format(value)
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Reads the column's footer-aggregate opt-in. A column opts into the table
|
|
112
|
+
* footer total via its manifest `display_config.aggregate` (mapped by the
|
|
113
|
+
* kernel to `styleConfig.aggregate` at runtime). Returns the aggregate kind
|
|
114
|
+
* (e.g. `'sum'`) or undefined when the column carries no footer total.
|
|
115
|
+
*/
|
|
116
|
+
export const aggregateOf = (col: ColumnDefinition): string | undefined => {
|
|
117
|
+
const v = styleCfg(col, 'aggregate')
|
|
118
|
+
return typeof v === 'string' && v !== '' ? v : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Formats a footer aggregate total with the SAME rules the body cells use:
|
|
123
|
+
* currency columns render as the org currency (resolveCurrency), number
|
|
124
|
+
* columns honour `styleConfig.decimals`, everything else falls back to a
|
|
125
|
+
* locale-formatted number. Non-numeric/empty totals render as a dash so an
|
|
126
|
+
* empty filtered set reads cleanly.
|
|
127
|
+
*/
|
|
128
|
+
export const formatAggregateTotal = (
|
|
129
|
+
col: ColumnDefinition,
|
|
130
|
+
value: unknown,
|
|
131
|
+
currency?: string,
|
|
132
|
+
locale?: string,
|
|
133
|
+
): string => {
|
|
134
|
+
const num = typeof value === 'number' ? value : Number(value)
|
|
135
|
+
if (value === null || value === undefined || isNaN(num)) return '—'
|
|
136
|
+
const renderAs = col.cellStyle ?? col.type
|
|
137
|
+
if (renderAs === 'currency') {
|
|
138
|
+
const decimals = styleCfg(col, 'decimals') ?? 2
|
|
139
|
+
return formatNumber(
|
|
140
|
+
num,
|
|
141
|
+
{
|
|
142
|
+
style: 'currency',
|
|
143
|
+
currency: resolveCurrency(col, currency),
|
|
144
|
+
minimumFractionDigits: decimals,
|
|
145
|
+
maximumFractionDigits: decimals,
|
|
146
|
+
},
|
|
147
|
+
locale,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
const decimals = styleCfg(col, 'decimals')
|
|
151
|
+
return formatNumber(
|
|
152
|
+
num,
|
|
153
|
+
decimals !== undefined
|
|
154
|
+
? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }
|
|
155
|
+
: {},
|
|
156
|
+
locale,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
110
160
|
/**
|
|
111
161
|
* Semantic status → badge color. Used by the `status` cell when no explicit
|
|
112
162
|
* `options` color is declared. Generic, value-driven mapping.
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
Table,
|
|
40
40
|
TableBody,
|
|
41
41
|
TableCell,
|
|
42
|
+
TableFooter,
|
|
42
43
|
TableHead,
|
|
43
44
|
TableHeader,
|
|
44
45
|
TableRow,
|
|
@@ -65,7 +66,7 @@ import { Progress } from './dialogs/_primitives'
|
|
|
65
66
|
import { useMetadataCache } from './metadata-cache'
|
|
66
67
|
import { useApi, useCurrentBranch } from './api-context'
|
|
67
68
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
68
|
-
import { defaultGetDynamicColumns, DATE_CELL_TYPES } from './dynamic-columns'
|
|
69
|
+
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
69
70
|
import { OptionsContext } from './options-context'
|
|
70
71
|
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
71
72
|
import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
|
|
@@ -130,6 +131,9 @@ export function DynamicTable({
|
|
|
130
131
|
|
|
131
132
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
132
133
|
const [data, setData] = useState<any[]>([])
|
|
134
|
+
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
135
|
+
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
136
|
+
const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
|
|
133
137
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
134
138
|
const [loadingData, setLoadingData] = useState(true)
|
|
135
139
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
@@ -424,19 +428,49 @@ export function DynamicTable({
|
|
|
424
428
|
}
|
|
425
429
|
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api])
|
|
426
430
|
|
|
431
|
+
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
432
|
+
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
433
|
+
// aggregate request is made.
|
|
434
|
+
const aggregateColumns = useMemo(
|
|
435
|
+
() => (metadata?.columns ?? []).filter((c) => aggregateOf(c as any)),
|
|
436
|
+
[metadata],
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// fetchAggregates GETs the SUM of each aggregate-flagged column over the SAME
|
|
440
|
+
// filtered set as the list (reuses buildFilterParams), then stores the totals
|
|
441
|
+
// keyed by column. Sort/pagination are irrelevant to a footer total and are
|
|
442
|
+
// omitted (the backend ignores them); only filters/search drive the result.
|
|
443
|
+
const fetchAggregates = useCallback(async () => {
|
|
444
|
+
if (!metadata || aggregateColumns.length === 0) return
|
|
445
|
+
try {
|
|
446
|
+
const { sortBy, order, ...filterParams } = buildFilterParams()
|
|
447
|
+
const base = endpoint || `/data/${model}`
|
|
448
|
+
const res = (await api.get(`${base}/aggregate`, { params: filterParams })) as {
|
|
449
|
+
data: ApiResponse<Record<string, any>>
|
|
450
|
+
}
|
|
451
|
+
if (res.data.success) setFooterTotals(res.data.data || {})
|
|
452
|
+
} catch (error) {
|
|
453
|
+
console.error('Error al cargar los totales', error)
|
|
454
|
+
}
|
|
455
|
+
}, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api])
|
|
456
|
+
|
|
427
457
|
const initialFetchDone = useRef(false)
|
|
428
458
|
useEffect(() => {
|
|
429
459
|
if (!metadata) return
|
|
430
460
|
if (!initialFetchDone.current) {
|
|
431
461
|
initialFetchDone.current = true
|
|
432
462
|
fetchData()
|
|
463
|
+
fetchAggregates()
|
|
433
464
|
return
|
|
434
465
|
}
|
|
435
|
-
const timeoutId = setTimeout(
|
|
466
|
+
const timeoutId = setTimeout(() => {
|
|
467
|
+
fetchData()
|
|
468
|
+
fetchAggregates()
|
|
469
|
+
}, 300)
|
|
436
470
|
return () => clearTimeout(timeoutId)
|
|
437
|
-
}, [fetchData, metadata])
|
|
471
|
+
}, [fetchData, fetchAggregates, metadata])
|
|
438
472
|
|
|
439
|
-
const handleRefresh = useCallback(() => { fetchData() }, [fetchData])
|
|
473
|
+
const handleRefresh = useCallback(() => { fetchData(); fetchAggregates() }, [fetchData, fetchAggregates])
|
|
440
474
|
|
|
441
475
|
const handleInternalAction = useCallback(async (action: string, row: any) => {
|
|
442
476
|
if (action === 'delete') { setRowToDelete(row); return }
|
|
@@ -777,6 +811,40 @@ export function DynamicTable({
|
|
|
777
811
|
</TableRow>
|
|
778
812
|
)}
|
|
779
813
|
</TableBody>
|
|
814
|
+
{aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (
|
|
815
|
+
<TableFooter>
|
|
816
|
+
<TableRow className="hover:bg-transparent">
|
|
817
|
+
{table.getVisibleLeafColumns().map((leaf: any, idx: number) => {
|
|
818
|
+
const col = (metadata?.columns ?? []).find(
|
|
819
|
+
(c) => c.key === leaf.id,
|
|
820
|
+
)
|
|
821
|
+
const isFirst = idx === 0
|
|
822
|
+
// Aggregate cell: render the SUM formatted like the body cell.
|
|
823
|
+
if (col && aggregateOf(col as any)) {
|
|
824
|
+
return (
|
|
825
|
+
<TableCell
|
|
826
|
+
key={leaf.id}
|
|
827
|
+
className="py-2 text-right font-semibold tabular-nums"
|
|
828
|
+
>
|
|
829
|
+
{formatAggregateTotal(
|
|
830
|
+
col as any,
|
|
831
|
+
footerTotals[leaf.id],
|
|
832
|
+
currency,
|
|
833
|
+
i18n.language,
|
|
834
|
+
)}
|
|
835
|
+
</TableCell>
|
|
836
|
+
)
|
|
837
|
+
}
|
|
838
|
+
// First non-aggregate column carries the "Total" label.
|
|
839
|
+
return (
|
|
840
|
+
<TableCell key={leaf.id} className="py-2 font-semibold">
|
|
841
|
+
{isFirst ? t('common.total', 'Total') : ''}
|
|
842
|
+
</TableCell>
|
|
843
|
+
)
|
|
844
|
+
})}
|
|
845
|
+
</TableRow>
|
|
846
|
+
</TableFooter>
|
|
847
|
+
)}
|
|
780
848
|
</Table>
|
|
781
849
|
</div>
|
|
782
850
|
|