@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 +29 -0
- package/dist/dynamic-columns.d.ts +15 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +80 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +44 -6
- package/package.json +3 -3
- package/src/dynamic-columns.tsx +119 -0
- package/src/dynamic-table.tsx +72 -4
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;
|
|
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.
|
|
@@ -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;
|
|
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": {
|
|
@@ -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.
|
|
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.
|
|
@@ -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
|
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
|
|