@asteby/metacore-runtime-react 18.8.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,34 @@
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
+
15
+ ## 18.9.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 5becc8e: Add a `reference` display type for SAP-style polymorphic source-document columns.
20
+
21
+ A column declared `display: "reference"` (e.g. `inventory_movements.source_id`,
22
+ whose target document varies by a `source_kind` discriminator) now renders a
23
+ navigable chip resolved by the backend. The new `ReferenceCell` reads the
24
+ resolved sibling `row[<key w/o _id>] = { value, label, kind, table }`: it shows
25
+ the `label` when present, else a short id (first 8 chars of the value), and —
26
+ when the sibling carries a target `table` and `value` — wraps the chip in a
27
+ plain `<a href="/m/<table>/<value>">` so the host router navigates to the source
28
+ document. Mirrors `RelationCell`'s chip look (subtle tint, dark-mode aware) and
29
+ is domain-agnostic: any polymorphic FK carrying the `reference` renderer works
30
+ without per-addon code.
31
+
3
32
  ## 18.8.0
4
33
 
5
34
  ### 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;AA0ED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAwmBnB;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.
@@ -340,6 +376,42 @@ const RelationCell = ({ col, row, getImageUrl }) => {
340
376
  const chipStyles = relationChipStyles(display, { isDark });
341
377
  return (_jsxs("span", { className: "inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-medium", style: chipStyles, title: display, children: [image && (_jsx(RelationThumbnail, { src: image, alt: display, getImageUrl: getImageUrl, size: 18 })), _jsx("span", { className: "truncate", children: display })] }));
342
378
  };
379
+ /**
380
+ * Renders a SAP-style polymorphic source-document reference as a navigable
381
+ * chip. Reads the backend-resolved sibling `row[<key w/o _id>] =
382
+ * { value, label, kind, table }` (see `relationKeyFor`) — the discriminator
383
+ * (`source_kind`) selects the target model and the backend stamps the resolved
384
+ * SQL `table` so the cell can link to `/m/<table>/<value>` (the host router
385
+ * handles `/m/:model/:id`). Shows the resolved `label` when present, else a
386
+ * short id (first 8 chars of the value). Domain-agnostic: any polymorphic FK
387
+ * (`source_id`, `document_id`, …) carrying `display: "reference"` works without
388
+ * per-addon code. Mirrors `RelationCell`'s chip look (subtle tint, dark-mode
389
+ * aware) so references read consistently next to plain relations.
390
+ */
391
+ const ReferenceCell = ({ col, row }) => {
392
+ const isDark = useIsDarkTheme();
393
+ const sibling = getNestedValue(row, relationKeyFor(col));
394
+ const value = (sibling && typeof sibling === 'object' ? sibling.value : undefined) ??
395
+ getNestedValue(row, col.key);
396
+ if (value === undefined || value === null || value === '' || isNilUuid(value)) {
397
+ return _jsx(EmptyCell, {});
398
+ }
399
+ const label = sibling && typeof sibling === 'object' ? sibling.label : undefined;
400
+ const kind = sibling && typeof sibling === 'object' ? sibling.kind : undefined;
401
+ const table = sibling && typeof sibling === 'object' ? sibling.table : undefined;
402
+ const displayText = label !== undefined && label !== null && label !== ''
403
+ ? String(label)
404
+ : `${String(value).slice(0, 8)}`;
405
+ // Tint keyed on the discriminator (when present) so e.g. sale/transfer/
406
+ // adjustment chips read as visually distinct families; falls back to the
407
+ // display text. Same subtle relation-chip look + dark-mode handling.
408
+ const chipStyles = relationChipStyles(String(kind || displayText), { isDark });
409
+ const className = 'inline-flex max-w-[220px] items-center gap-1 rounded-md px-2 py-0.5 text-sm font-medium';
410
+ if (table && value) {
411
+ return (_jsx("a", { href: `/m/${table}/${value}`, onClick: (e) => e.stopPropagation(), className: `${className} hover:underline`, style: chipStyles, title: displayText, children: _jsx("span", { className: "truncate", children: displayText }) }));
412
+ }
413
+ return (_jsx("span", { className: className, style: chipStyles, title: displayText, children: _jsx("span", { className: "truncate", children: displayText }) }));
414
+ };
343
415
  /**
344
416
  * Generic avatar-style cell: round/rounded photo (or initials fallback) +
345
417
  * primary name + optional subtitle. Backs the `avatar`/`search` columns as
@@ -444,6 +516,14 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
444
516
  // `in_progress` reads as "In Progress" instead of raw.
445
517
  return (_jsx(Badge, { variant: "outline", className: "border-0", style: styles, children: humanizeToken(sv) }));
446
518
  }
519
+ // Polymorphic source-document reference (SAP-style). Reads
520
+ // the backend-resolved `{ value, label, kind, table }`
521
+ // sibling and renders a navigable `/m/<table>/<value>` chip.
522
+ // Checked before the relation branch so a polymorphic FK
523
+ // carrying a `ref` still routes here.
524
+ if (renderAs === 'reference') {
525
+ return _jsx(ReferenceCell, { col: col, row: row.original });
526
+ }
447
527
  // Resolved FK relation chip. Triggers on an explicit
448
528
  // `cellStyle: 'relation'` or on any column carrying a `ref`
449
529
  // (a belongs_to FK) that isn't being rendered as an
@@ -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.8.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": {
@@ -62,7 +62,7 @@
62
62
  "vitest": "^4.0.0",
63
63
  "zustand": "^5.0.0",
64
64
  "@asteby/metacore-sdk": "3.2.0",
65
- "@asteby/metacore-ui": "2.5.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.
@@ -509,6 +559,66 @@ const RelationCell: React.FC<{
509
559
  )
510
560
  }
511
561
 
562
+ /**
563
+ * Renders a SAP-style polymorphic source-document reference as a navigable
564
+ * chip. Reads the backend-resolved sibling `row[<key w/o _id>] =
565
+ * { value, label, kind, table }` (see `relationKeyFor`) — the discriminator
566
+ * (`source_kind`) selects the target model and the backend stamps the resolved
567
+ * SQL `table` so the cell can link to `/m/<table>/<value>` (the host router
568
+ * handles `/m/:model/:id`). Shows the resolved `label` when present, else a
569
+ * short id (first 8 chars of the value). Domain-agnostic: any polymorphic FK
570
+ * (`source_id`, `document_id`, …) carrying `display: "reference"` works without
571
+ * per-addon code. Mirrors `RelationCell`'s chip look (subtle tint, dark-mode
572
+ * aware) so references read consistently next to plain relations.
573
+ */
574
+ const ReferenceCell: React.FC<{
575
+ col: ColumnDefinition
576
+ row: any
577
+ }> = ({ col, row }) => {
578
+ const isDark = useIsDarkTheme()
579
+ const sibling = getNestedValue(row, relationKeyFor(col))
580
+ const value =
581
+ (sibling && typeof sibling === 'object' ? sibling.value : undefined) ??
582
+ getNestedValue(row, col.key)
583
+ if (value === undefined || value === null || value === '' || isNilUuid(value)) {
584
+ return <EmptyCell />
585
+ }
586
+ const label =
587
+ sibling && typeof sibling === 'object' ? sibling.label : undefined
588
+ const kind =
589
+ sibling && typeof sibling === 'object' ? sibling.kind : undefined
590
+ const table =
591
+ sibling && typeof sibling === 'object' ? sibling.table : undefined
592
+ const displayText =
593
+ label !== undefined && label !== null && label !== ''
594
+ ? String(label)
595
+ : `${String(value).slice(0, 8)}`
596
+ // Tint keyed on the discriminator (when present) so e.g. sale/transfer/
597
+ // adjustment chips read as visually distinct families; falls back to the
598
+ // display text. Same subtle relation-chip look + dark-mode handling.
599
+ const chipStyles = relationChipStyles(String(kind || displayText), { isDark })
600
+ const className =
601
+ 'inline-flex max-w-[220px] items-center gap-1 rounded-md px-2 py-0.5 text-sm font-medium'
602
+ if (table && value) {
603
+ return (
604
+ <a
605
+ href={`/m/${table}/${value}`}
606
+ onClick={(e) => e.stopPropagation()}
607
+ className={`${className} hover:underline`}
608
+ style={chipStyles}
609
+ title={displayText}
610
+ >
611
+ <span className="truncate">{displayText}</span>
612
+ </a>
613
+ )
614
+ }
615
+ return (
616
+ <span className={className} style={chipStyles} title={displayText}>
617
+ <span className="truncate">{displayText}</span>
618
+ </span>
619
+ )
620
+ }
621
+
512
622
  /**
513
623
  * Generic avatar-style cell: round/rounded photo (or initials fallback) +
514
624
  * primary name + optional subtitle. Backs the `avatar`/`search` columns as
@@ -682,6 +792,15 @@ export function makeDefaultGetDynamicColumns(
682
792
  )
683
793
  }
684
794
 
795
+ // Polymorphic source-document reference (SAP-style). Reads
796
+ // the backend-resolved `{ value, label, kind, table }`
797
+ // sibling and renders a navigable `/m/<table>/<value>` chip.
798
+ // Checked before the relation branch so a polymorphic FK
799
+ // carrying a `ref` still routes here.
800
+ if (renderAs === 'reference') {
801
+ return <ReferenceCell col={col} row={row.original} />
802
+ }
803
+
685
804
  // Resolved FK relation chip. Triggers on an explicit
686
805
  // `cellStyle: 'relation'` or on any column carrying a `ref`
687
806
  // (a belongs_to FK) that isn't being rendered as an
@@ -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