@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 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;AAoErD;;;;;;;;;;;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"}
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"}
@@ -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;AA+B9B,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,+BAgyBnB"}
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"}
@@ -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(fetchData, 300);
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." })] })] }) }) })) })] }) }), _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) => {
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.9.0",
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.0"
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-ui": "2.5.0",
65
- "@asteby/metacore-sdk": "3.2.0"
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",
@@ -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.
@@ -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(fetchData, 300)
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