@asteby/metacore-runtime-react 23.12.3 → 24.0.1
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 +32 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +141 -2
- package/dist/dialogs/dynamic-record.d.ts +3 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +3 -3
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +68 -6
- package/package.json +3 -3
- package/src/action-modal-dispatcher.tsx +177 -2
- package/src/dialogs/dynamic-record.tsx +3 -3
- package/src/dynamic-table.tsx +91 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 24.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4a62c3c: Preview automático del registro en el modal de confirmación de acciones de fila.
|
|
8
|
+
|
|
9
|
+
Al confirmar una row-action con `confirm` (p. ej. aceptar/rechazar un traspaso), el
|
|
10
|
+
`ConfirmActionDialog` ahora muestra un resumen compacto y de solo lectura del registro
|
|
11
|
+
que va a afectar, para no confirmar a ciegas. Es 100% del SDK y genérico: se apoya en la
|
|
12
|
+
metadata de tabla del modelo (labels + display hints, leída del cache o con un único fetch
|
|
13
|
+
a `/metadata/table/<model>`) y en los siblings de relación que la tabla ya resolvió sobre
|
|
14
|
+
la fila. La heurística surface relaciones resueltas a su label, campos line-items (jsonb,
|
|
15
|
+
como `Transfer.items`, renderizados producto × cantidad) y un puñado de escalares de
|
|
16
|
+
identidad; omite `id`, `organization_id`, timestamps y los `*_id` crudos sin label. Se
|
|
17
|
+
degrada solo: si no hay nada útil que mostrar, no renderiza la sección.
|
|
18
|
+
|
|
19
|
+
## 24.0.0
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 165f25c: feat(dynamic-table): cache first-page rows for instant reload paint
|
|
24
|
+
|
|
25
|
+
The table fetched rows into local state, so a full reload showed a full-table
|
|
26
|
+
skeleton until `/data/:model` resolved — even for the view just seen. Stash the
|
|
27
|
+
last first-page result in sessionStorage (org/user-scoped → must not outlive the
|
|
28
|
+
tab session) keyed by model+endpoint+branch+URL params, and seed the initial
|
|
29
|
+
rows from it so a reload paints instantly and revalidates in the background
|
|
30
|
+
(stale-while-revalidate).
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [0704d54]
|
|
33
|
+
- @asteby/metacore-ui@2.10.0
|
|
34
|
+
|
|
3
35
|
## 23.12.3
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AAwDA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AA0FhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCA+DlB"}
|
|
@@ -19,8 +19,10 @@ import { DynamicRelations } from './dynamic-relations';
|
|
|
19
19
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
20
20
|
import { DynamicDateField } from './dynamic-date-field';
|
|
21
21
|
import { UploadField } from './upload-field';
|
|
22
|
-
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn } from './dynamic-form-schema';
|
|
22
|
+
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn, getFieldRef } from './dynamic-form-schema';
|
|
23
23
|
import { FieldGrid, FieldCell, FieldLabel } from './field-grid';
|
|
24
|
+
import { useMetadataCache } from './metadata-cache';
|
|
25
|
+
import { ViewValue, objectLabel, relationSiblingValue, isLineItemsField as isRecordLineItemsField, } from './dialogs/dynamic-record';
|
|
24
26
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
25
27
|
import { getActionComponent, } from '@asteby/metacore-sdk';
|
|
26
28
|
function isPrefillSpec(v) {
|
|
@@ -110,6 +112,143 @@ function buildActionUrl(endpoint, model, recordId, actionKey) {
|
|
|
110
112
|
}
|
|
111
113
|
return hasRecord ? `/data/${model}/me/${recordId}/action/${actionKey}` : `/data/${model}/me/action/${actionKey}`;
|
|
112
114
|
}
|
|
115
|
+
// ── Preview automático del registro en el modal de confirmación ─────────────
|
|
116
|
+
//
|
|
117
|
+
// Antes de confirmar una row-action (aceptar/rechazar un traspaso, etc.) el
|
|
118
|
+
// usuario ve un resumen compacto y read-only de QUÉ registro va a afectar, para
|
|
119
|
+
// no confirmar a ciegas. Es 100% del SDK y genérico: se apoya en la metadata de
|
|
120
|
+
// tabla del modelo (labels + display hints) y en los siblings de relación que la
|
|
121
|
+
// tabla ya resolvió sobre el `record`. Se degrada solo — si no hay ni un campo
|
|
122
|
+
// ni líneas útiles que mostrar, no renderiza nada (ni una caja vacía).
|
|
123
|
+
// Claves de sistema/infra que nunca aportan contexto al preview.
|
|
124
|
+
const PREVIEW_SKIP_KEYS = new Set([
|
|
125
|
+
'id',
|
|
126
|
+
'organization_id',
|
|
127
|
+
'org_id',
|
|
128
|
+
'created_at',
|
|
129
|
+
'updated_at',
|
|
130
|
+
'deleted_at',
|
|
131
|
+
'created_by',
|
|
132
|
+
'created_by_id',
|
|
133
|
+
'updated_by',
|
|
134
|
+
'updated_by_id',
|
|
135
|
+
]);
|
|
136
|
+
// Campos escalares "de identidad" que sí vale la pena anclar en el preview
|
|
137
|
+
// aunque no sean relación ni line-items (nombre, folio, estado, etc.).
|
|
138
|
+
const PREVIEW_INTEREST_KEYS = new Set([
|
|
139
|
+
'name',
|
|
140
|
+
'title',
|
|
141
|
+
'code',
|
|
142
|
+
'reference',
|
|
143
|
+
'folio',
|
|
144
|
+
'number',
|
|
145
|
+
'status',
|
|
146
|
+
'stage',
|
|
147
|
+
'state',
|
|
148
|
+
]);
|
|
149
|
+
const PREVIEW_INTEREST_STYLES = new Set(['status', 'badge', 'currency']);
|
|
150
|
+
const MAX_PREVIEW_FIELD_ROWS = 6;
|
|
151
|
+
function isEmptyPreviewValue(value) {
|
|
152
|
+
if (value === null || value === undefined || value === '')
|
|
153
|
+
return true;
|
|
154
|
+
if (Array.isArray(value))
|
|
155
|
+
return value.length === 0;
|
|
156
|
+
if (typeof value === 'object' && !(value instanceof Date))
|
|
157
|
+
return Object.keys(value).length === 0;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
// selectPreviewColumns aplica la heurística compacta sobre las columnas del
|
|
161
|
+
// modelo: relaciones resueltas a su label, campos line-items (jsonb) y un puñado
|
|
162
|
+
// de escalares de identidad. Omite id/org/timestamps y los *_id crudos cuya
|
|
163
|
+
// relación no resolvió a un label legible.
|
|
164
|
+
function selectPreviewColumns(columns, record) {
|
|
165
|
+
if (!columns || !record)
|
|
166
|
+
return [];
|
|
167
|
+
const lineItemRows = [];
|
|
168
|
+
const fieldRows = [];
|
|
169
|
+
for (const col of columns) {
|
|
170
|
+
if (!col || !col.key)
|
|
171
|
+
continue;
|
|
172
|
+
if (col.hidden)
|
|
173
|
+
continue;
|
|
174
|
+
if (PREVIEW_SKIP_KEYS.has(col.key))
|
|
175
|
+
continue;
|
|
176
|
+
const value = record[col.key];
|
|
177
|
+
// Line-items (jsonb array como Transfer.items) → mini-tabla producto×cantidad.
|
|
178
|
+
if (isRecordLineItemsField(col, value)) {
|
|
179
|
+
if (isEmptyPreviewValue(value))
|
|
180
|
+
continue;
|
|
181
|
+
lineItemRows.push({ col, value, lineItems: true });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Relación (ref / search / dynamic_select / *_id): solo si el sibling
|
|
185
|
+
// resolvió a un label legible; si es un *_id crudo sin resolver, se omite.
|
|
186
|
+
const isRelation = !!getFieldRef(col) ||
|
|
187
|
+
col.type === 'search' ||
|
|
188
|
+
col.type === 'relation' ||
|
|
189
|
+
col.widget === 'dynamic_select' ||
|
|
190
|
+
(typeof col.key === 'string' && col.key.endsWith('_id'));
|
|
191
|
+
if (isRelation) {
|
|
192
|
+
const sib = relationSiblingValue(col, record);
|
|
193
|
+
const label = typeof sib === 'string' ? sib : objectLabel(sib);
|
|
194
|
+
if (!label)
|
|
195
|
+
continue;
|
|
196
|
+
fieldRows.push({ col, value, lineItems: false });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Escalar de identidad (nombre/folio/estado/moneda…) con valor.
|
|
200
|
+
const styleKey = col.cellStyle ?? col.type;
|
|
201
|
+
const interesting = PREVIEW_INTEREST_KEYS.has(col.key) || PREVIEW_INTEREST_STYLES.has(String(styleKey));
|
|
202
|
+
if (interesting && !isEmptyPreviewValue(value)) {
|
|
203
|
+
fieldRows.push({ col, value, lineItems: false });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [...fieldRows.slice(0, MAX_PREVIEW_FIELD_ROWS), ...lineItemRows];
|
|
207
|
+
}
|
|
208
|
+
function RecordPreview({ model, record }) {
|
|
209
|
+
const { t } = useTranslation();
|
|
210
|
+
const api = useApi();
|
|
211
|
+
const cached = useMetadataCache((s) => s.getMetadata(model));
|
|
212
|
+
const setMetadata = useMetadataCache((s) => s.setMetadata);
|
|
213
|
+
const [fetched, setFetched] = useState(null);
|
|
214
|
+
// Sin metadata cacheada → UN fetch a /metadata/table/<model> (mismo patrón
|
|
215
|
+
// que model-action-toolbar). Se guarda en el store para próximos usos.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (cached || !model)
|
|
218
|
+
return;
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
api
|
|
221
|
+
.get(`/metadata/table/${model}`)
|
|
222
|
+
.then((res) => {
|
|
223
|
+
if (cancelled)
|
|
224
|
+
return;
|
|
225
|
+
const meta = (res.data?.data ?? res.data);
|
|
226
|
+
if (meta && Array.isArray(meta.columns)) {
|
|
227
|
+
setFetched(meta);
|
|
228
|
+
setMetadata(model, meta);
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
.catch(() => {
|
|
232
|
+
if (!cancelled)
|
|
233
|
+
setFetched(null);
|
|
234
|
+
});
|
|
235
|
+
return () => {
|
|
236
|
+
cancelled = true;
|
|
237
|
+
};
|
|
238
|
+
}, [cached, model, api, setMetadata]);
|
|
239
|
+
const meta = cached ?? fetched;
|
|
240
|
+
const rows = useMemo(() => selectPreviewColumns(meta?.columns, record), [meta, record]);
|
|
241
|
+
// Degradación: nada útil que mostrar → no renderiza la sección.
|
|
242
|
+
if (rows.length === 0)
|
|
243
|
+
return null;
|
|
244
|
+
return (_jsx("div", { className: "mt-1 overflow-hidden rounded-md border bg-muted/30 text-sm", children: rows.map(({ col, value, lineItems }) => {
|
|
245
|
+
const label = t(col.label, { defaultValue: col.label });
|
|
246
|
+
if (lineItems) {
|
|
247
|
+
return (_jsxs("div", { className: "border-t px-3 py-2 first:border-t-0", children: [_jsx("div", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: label }), _jsx(ViewValue, { field: col, value: value, record: record })] }, col.key));
|
|
248
|
+
}
|
|
249
|
+
return (_jsxs("div", { className: "flex items-baseline gap-3 border-t px-3 py-1.5 first:border-t-0", children: [_jsx("span", { className: "w-1/3 shrink-0 truncate text-muted-foreground", children: label }), _jsx("div", { className: "min-w-0 flex-1 [&_p]:py-0", children: _jsx(ViewValue, { field: col, value: value, record: record }) })] }, col.key));
|
|
250
|
+
}) }));
|
|
251
|
+
}
|
|
113
252
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }) {
|
|
114
253
|
const { t } = useTranslation();
|
|
115
254
|
const api = useApi();
|
|
@@ -139,7 +278,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
139
278
|
setExecuting(false);
|
|
140
279
|
}
|
|
141
280
|
};
|
|
142
|
-
return (_jsx(AlertDialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsxs(AlertDialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), label] }), _jsx(AlertDialogDescription, { children: action.confirmMessage || `${label}?` })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: executing, children: t('common.cancel') }), _jsxs(AlertDialogAction, { onClick: (e) => { e.preventDefault(); execute(); }, disabled: executing, style: action.color ? { backgroundColor: action.color } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), label] })] })] }) }));
|
|
281
|
+
return (_jsx(AlertDialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsxs(AlertDialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), label] }), _jsx(AlertDialogDescription, { children: action.confirmMessage || `${label}?` })] }), record ? _jsx(RecordPreview, { model: model, record: record }) : null, _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: executing, children: t('common.cancel') }), _jsxs(AlertDialogAction, { onClick: (e) => { e.preventDefault(); execute(); }, disabled: executing, style: action.color ? { backgroundColor: action.color } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), label] })] })] }) }));
|
|
143
282
|
}
|
|
144
283
|
function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }) {
|
|
145
284
|
const { t } = useTranslation();
|
|
@@ -159,6 +159,9 @@ export interface DynamicRecordDialogProps {
|
|
|
159
159
|
*/
|
|
160
160
|
onChange?: () => void;
|
|
161
161
|
}
|
|
162
|
+
export declare function objectLabel(value: any): string | undefined;
|
|
163
|
+
export declare function relationSiblingValue(field: FieldDef, record: any): any;
|
|
164
|
+
export declare function fieldItemFields(field: FieldDef): ItemField[] | undefined;
|
|
162
165
|
export declare function isLineItemsField(field: FieldDef, value: any): boolean;
|
|
163
166
|
export declare function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null;
|
|
164
167
|
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -47,7 +47,7 @@ function resolvePath(obj, path) {
|
|
|
47
47
|
// objectLabel pulls a human label off a resolved relation/user object the
|
|
48
48
|
// backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
|
|
49
49
|
// as created_by), or `{title}`. Returns undefined for plain/empty objects.
|
|
50
|
-
function objectLabel(value) {
|
|
50
|
+
export function objectLabel(value) {
|
|
51
51
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
52
52
|
return undefined;
|
|
53
53
|
const label = value.label ?? value.name ?? value.title;
|
|
@@ -66,7 +66,7 @@ function pickImage(value) {
|
|
|
66
66
|
// FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
|
|
67
67
|
// `record.category = {value,label,image?}` (or a bare string/{name}); returns
|
|
68
68
|
// the raw sibling (object or string) so the caller can extract label + image.
|
|
69
|
-
function relationSiblingValue(field, record) {
|
|
69
|
+
export function relationSiblingValue(field, record) {
|
|
70
70
|
if (!record)
|
|
71
71
|
return undefined;
|
|
72
72
|
const candidates = [];
|
|
@@ -91,7 +91,7 @@ function relationSiblingValue(field, record) {
|
|
|
91
91
|
}
|
|
92
92
|
// fieldItemFields reads the declared jsonb line-items schema off a field,
|
|
93
93
|
// tolerating the snake_case `item_fields` alias the kernel serves.
|
|
94
|
-
function fieldItemFields(field) {
|
|
94
|
+
export function fieldItemFields(field) {
|
|
95
95
|
return field.itemFields ?? field.item_fields;
|
|
96
96
|
}
|
|
97
97
|
// isLineItemsField — a jsonb line-items column (e.g. Transfer.items): it either
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiEnF,MAAM,WAAW,iBAAiB;IAC9B,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;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,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;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAupCnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -33,6 +33,48 @@ import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-c
|
|
|
33
33
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
34
34
|
import { ExportDialog } from './dialogs/export';
|
|
35
35
|
import { ImportDialog } from './dialogs/import';
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Row-data cache (perceived performance).
|
|
38
|
+
//
|
|
39
|
+
// The table fetches rows into local state, so a full page reload starts empty
|
|
40
|
+
// and shows a full skeleton until `/data/:model` resolves — even for a view the
|
|
41
|
+
// user just looked at. We stash the last first-page result and seed the initial
|
|
42
|
+
// state from it so a reload paints the previous rows instantly and the fetch
|
|
43
|
+
// below revalidates in the background (stale-while-revalidate).
|
|
44
|
+
//
|
|
45
|
+
// Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
|
|
46
|
+
// so it must not outlive the tab session (a browser restart or a different login
|
|
47
|
+
// starts clean). The key includes model+endpoint+branch+URL params so a
|
|
48
|
+
// different filter / sort / page never paints the wrong rows. Only the first
|
|
49
|
+
// page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
|
|
50
|
+
const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1';
|
|
51
|
+
function tableDataCacheKey(model, endpoint, branchId, search) {
|
|
52
|
+
const p = new URLSearchParams(search);
|
|
53
|
+
const parts = [];
|
|
54
|
+
p.forEach((v, k) => parts.push(`${k}=${v}`));
|
|
55
|
+
parts.sort();
|
|
56
|
+
return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`;
|
|
57
|
+
}
|
|
58
|
+
function readTableDataCache(key) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = sessionStorage.getItem(key);
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
return parsed && Array.isArray(parsed.rows) ? parsed : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function writeTableDataCache(key, rows, rowCount) {
|
|
71
|
+
try {
|
|
72
|
+
sessionStorage.setItem(key, JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// quota / private mode — the cache is a nicety, never fatal
|
|
76
|
+
}
|
|
77
|
+
}
|
|
36
78
|
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
|
|
37
79
|
const { t, i18n } = useTranslation();
|
|
38
80
|
const api = useApi();
|
|
@@ -41,12 +83,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
41
83
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache();
|
|
42
84
|
const cachedMeta = getMetadata(model);
|
|
43
85
|
const [metadata, setMetadata] = useState(cachedMeta || null);
|
|
44
|
-
|
|
86
|
+
// Read the row-data cache ONCE at mount (before the fetch effects run) so the
|
|
87
|
+
// first paint uses the previous rows for this exact view instead of skeletons.
|
|
88
|
+
const bootDataKey = tableDataCacheKey(model, endpoint, currentBranch?.id, typeof window !== 'undefined' ? window.location.search : '');
|
|
89
|
+
const bootDataRef = useRef(undefined);
|
|
90
|
+
if (bootDataRef.current === undefined) {
|
|
91
|
+
bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null;
|
|
92
|
+
}
|
|
93
|
+
const bootData = bootDataRef.current;
|
|
94
|
+
const [data, setData] = useState(bootData?.rows ?? []);
|
|
45
95
|
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
46
96
|
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
47
97
|
const [footerTotals, setFooterTotals] = useState({});
|
|
48
98
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
49
|
-
|
|
99
|
+
// Cached rows → no full-table skeleton on reload; the background fetch still
|
|
100
|
+
// runs and swaps in fresh data.
|
|
101
|
+
const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length));
|
|
50
102
|
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
51
103
|
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
52
104
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -72,7 +124,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
72
124
|
const [columnFilters, setColumnFilters] = useState([]);
|
|
73
125
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
|
74
126
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
75
|
-
const [rowCount, setRowCount] = useState(0);
|
|
127
|
+
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0);
|
|
76
128
|
const [dateRange, setDateRange] = useState(undefined);
|
|
77
129
|
const [dynamicFilters, setDynamicFilters] = useState({});
|
|
78
130
|
const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
|
|
@@ -475,9 +527,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
475
527
|
};
|
|
476
528
|
const res = await api.get(endpoint || `/data/${model}`, { params });
|
|
477
529
|
if (res.data.success) {
|
|
478
|
-
|
|
530
|
+
const rows = res.data.data || [];
|
|
531
|
+
setData(rows);
|
|
479
532
|
if (res.data.meta)
|
|
480
533
|
setRowCount(res.data.meta.total);
|
|
534
|
+
// Cache the first page for an instant reload paint (see the cache
|
|
535
|
+
// helpers). Keyed off the live URL so it matches the next mount.
|
|
536
|
+
if (enableUrlSync && pagination.pageIndex === 0) {
|
|
537
|
+
writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
|
|
538
|
+
}
|
|
481
539
|
}
|
|
482
540
|
}
|
|
483
541
|
catch (error) {
|
|
@@ -486,7 +544,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
486
544
|
finally {
|
|
487
545
|
setLoadingData(false);
|
|
488
546
|
}
|
|
489
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api]);
|
|
547
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync]);
|
|
490
548
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
491
549
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
492
550
|
// aggregate request is made.
|
|
@@ -532,6 +590,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
532
590
|
setData((prev) => (append ? dedupeById(prev, rows) : rows));
|
|
533
591
|
if (res.data.meta)
|
|
534
592
|
setRowCount(res.data.meta.total);
|
|
593
|
+
// Cache the first (replace) page for an instant reload paint.
|
|
594
|
+
if (!append && enableUrlSync) {
|
|
595
|
+
writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
|
|
596
|
+
}
|
|
535
597
|
// A short page means the backend has no more rows, even if
|
|
536
598
|
// meta.total disagrees with the visible count (count query
|
|
537
599
|
// vs list query drift, dedupe). Without this the sentinel
|
|
@@ -548,7 +610,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
548
610
|
else
|
|
549
611
|
setLoadingData(false);
|
|
550
612
|
}
|
|
551
|
-
}, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id]);
|
|
613
|
+
}, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync]);
|
|
552
614
|
// Signature of everything that must reset the incremental list to page 1:
|
|
553
615
|
// the filters/search AND the sort (both live in buildFilterParams).
|
|
554
616
|
const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.1",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.
|
|
41
|
+
"@asteby/metacore-ui": "^2.10.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.
|
|
71
|
+
"@asteby/metacore-ui": "2.10.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -42,9 +42,17 @@ import { DynamicRelations } from './dynamic-relations'
|
|
|
42
42
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
43
43
|
import { DynamicDateField } from './dynamic-date-field'
|
|
44
44
|
import { UploadField } from './upload-field'
|
|
45
|
-
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn } from './dynamic-form-schema'
|
|
45
|
+
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn, getFieldRef } from './dynamic-form-schema'
|
|
46
46
|
import { FieldGrid, FieldCell, FieldLabel } from './field-grid'
|
|
47
|
-
import
|
|
47
|
+
import { useMetadataCache } from './metadata-cache'
|
|
48
|
+
import {
|
|
49
|
+
ViewValue,
|
|
50
|
+
objectLabel,
|
|
51
|
+
relationSiblingValue,
|
|
52
|
+
fieldItemFields,
|
|
53
|
+
isLineItemsField as isRecordLineItemsField,
|
|
54
|
+
} from './dialogs/dynamic-record'
|
|
55
|
+
import type { ActionFieldDef, TableMetadata, ColumnDefinition } from './types'
|
|
48
56
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
49
57
|
import {
|
|
50
58
|
type ActionMetadata,
|
|
@@ -228,6 +236,172 @@ function buildActionUrl(endpoint: string | undefined, model: string, recordId: s
|
|
|
228
236
|
return hasRecord ? `/data/${model}/me/${recordId}/action/${actionKey}` : `/data/${model}/me/action/${actionKey}`
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
// ── Preview automático del registro en el modal de confirmación ─────────────
|
|
240
|
+
//
|
|
241
|
+
// Antes de confirmar una row-action (aceptar/rechazar un traspaso, etc.) el
|
|
242
|
+
// usuario ve un resumen compacto y read-only de QUÉ registro va a afectar, para
|
|
243
|
+
// no confirmar a ciegas. Es 100% del SDK y genérico: se apoya en la metadata de
|
|
244
|
+
// tabla del modelo (labels + display hints) y en los siblings de relación que la
|
|
245
|
+
// tabla ya resolvió sobre el `record`. Se degrada solo — si no hay ni un campo
|
|
246
|
+
// ni líneas útiles que mostrar, no renderiza nada (ni una caja vacía).
|
|
247
|
+
|
|
248
|
+
// Claves de sistema/infra que nunca aportan contexto al preview.
|
|
249
|
+
const PREVIEW_SKIP_KEYS = new Set([
|
|
250
|
+
'id',
|
|
251
|
+
'organization_id',
|
|
252
|
+
'org_id',
|
|
253
|
+
'created_at',
|
|
254
|
+
'updated_at',
|
|
255
|
+
'deleted_at',
|
|
256
|
+
'created_by',
|
|
257
|
+
'created_by_id',
|
|
258
|
+
'updated_by',
|
|
259
|
+
'updated_by_id',
|
|
260
|
+
])
|
|
261
|
+
|
|
262
|
+
// Campos escalares "de identidad" que sí vale la pena anclar en el preview
|
|
263
|
+
// aunque no sean relación ni line-items (nombre, folio, estado, etc.).
|
|
264
|
+
const PREVIEW_INTEREST_KEYS = new Set([
|
|
265
|
+
'name',
|
|
266
|
+
'title',
|
|
267
|
+
'code',
|
|
268
|
+
'reference',
|
|
269
|
+
'folio',
|
|
270
|
+
'number',
|
|
271
|
+
'status',
|
|
272
|
+
'stage',
|
|
273
|
+
'state',
|
|
274
|
+
])
|
|
275
|
+
const PREVIEW_INTEREST_STYLES = new Set(['status', 'badge', 'currency'])
|
|
276
|
+
|
|
277
|
+
const MAX_PREVIEW_FIELD_ROWS = 6
|
|
278
|
+
|
|
279
|
+
function isEmptyPreviewValue(value: any): boolean {
|
|
280
|
+
if (value === null || value === undefined || value === '') return true
|
|
281
|
+
if (Array.isArray(value)) return value.length === 0
|
|
282
|
+
if (typeof value === 'object' && !(value instanceof Date)) return Object.keys(value).length === 0
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface PreviewRow {
|
|
287
|
+
col: ColumnDefinition
|
|
288
|
+
value: any
|
|
289
|
+
lineItems: boolean
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// selectPreviewColumns aplica la heurística compacta sobre las columnas del
|
|
293
|
+
// modelo: relaciones resueltas a su label, campos line-items (jsonb) y un puñado
|
|
294
|
+
// de escalares de identidad. Omite id/org/timestamps y los *_id crudos cuya
|
|
295
|
+
// relación no resolvió a un label legible.
|
|
296
|
+
function selectPreviewColumns(columns: ColumnDefinition[] | undefined, record: any): PreviewRow[] {
|
|
297
|
+
if (!columns || !record) return []
|
|
298
|
+
const lineItemRows: PreviewRow[] = []
|
|
299
|
+
const fieldRows: PreviewRow[] = []
|
|
300
|
+
|
|
301
|
+
for (const col of columns) {
|
|
302
|
+
if (!col || !col.key) continue
|
|
303
|
+
if (col.hidden) continue
|
|
304
|
+
if (PREVIEW_SKIP_KEYS.has(col.key)) continue
|
|
305
|
+
const value = record[col.key]
|
|
306
|
+
|
|
307
|
+
// Line-items (jsonb array como Transfer.items) → mini-tabla producto×cantidad.
|
|
308
|
+
if (isRecordLineItemsField(col as any, value)) {
|
|
309
|
+
if (isEmptyPreviewValue(value)) continue
|
|
310
|
+
lineItemRows.push({ col, value, lineItems: true })
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Relación (ref / search / dynamic_select / *_id): solo si el sibling
|
|
315
|
+
// resolvió a un label legible; si es un *_id crudo sin resolver, se omite.
|
|
316
|
+
const isRelation =
|
|
317
|
+
!!getFieldRef(col as ActionFieldDef) ||
|
|
318
|
+
col.type === 'search' ||
|
|
319
|
+
col.type === 'relation' ||
|
|
320
|
+
(col as { widget?: string }).widget === 'dynamic_select' ||
|
|
321
|
+
(typeof col.key === 'string' && col.key.endsWith('_id'))
|
|
322
|
+
if (isRelation) {
|
|
323
|
+
const sib = relationSiblingValue(col as any, record)
|
|
324
|
+
const label = typeof sib === 'string' ? sib : objectLabel(sib)
|
|
325
|
+
if (!label) continue
|
|
326
|
+
fieldRows.push({ col, value, lineItems: false })
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Escalar de identidad (nombre/folio/estado/moneda…) con valor.
|
|
331
|
+
const styleKey = col.cellStyle ?? col.type
|
|
332
|
+
const interesting =
|
|
333
|
+
PREVIEW_INTEREST_KEYS.has(col.key) || PREVIEW_INTEREST_STYLES.has(String(styleKey))
|
|
334
|
+
if (interesting && !isEmptyPreviewValue(value)) {
|
|
335
|
+
fieldRows.push({ col, value, lineItems: false })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return [...fieldRows.slice(0, MAX_PREVIEW_FIELD_ROWS), ...lineItemRows]
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function RecordPreview({ model, record }: { model: string; record: any }) {
|
|
343
|
+
const { t } = useTranslation()
|
|
344
|
+
const api = useApi()
|
|
345
|
+
const cached = useMetadataCache((s) => s.getMetadata(model))
|
|
346
|
+
const setMetadata = useMetadataCache((s) => s.setMetadata)
|
|
347
|
+
const [fetched, setFetched] = useState<TableMetadata | null>(null)
|
|
348
|
+
|
|
349
|
+
// Sin metadata cacheada → UN fetch a /metadata/table/<model> (mismo patrón
|
|
350
|
+
// que model-action-toolbar). Se guarda en el store para próximos usos.
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (cached || !model) return
|
|
353
|
+
let cancelled = false
|
|
354
|
+
api
|
|
355
|
+
.get(`/metadata/table/${model}`)
|
|
356
|
+
.then((res) => {
|
|
357
|
+
if (cancelled) return
|
|
358
|
+
const meta = (res.data?.data ?? res.data) as TableMetadata
|
|
359
|
+
if (meta && Array.isArray(meta.columns)) {
|
|
360
|
+
setFetched(meta)
|
|
361
|
+
setMetadata(model, meta)
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
.catch(() => {
|
|
365
|
+
if (!cancelled) setFetched(null)
|
|
366
|
+
})
|
|
367
|
+
return () => {
|
|
368
|
+
cancelled = true
|
|
369
|
+
}
|
|
370
|
+
}, [cached, model, api, setMetadata])
|
|
371
|
+
|
|
372
|
+
const meta = cached ?? fetched
|
|
373
|
+
const rows = useMemo(() => selectPreviewColumns(meta?.columns, record), [meta, record])
|
|
374
|
+
|
|
375
|
+
// Degradación: nada útil que mostrar → no renderiza la sección.
|
|
376
|
+
if (rows.length === 0) return null
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<div className="mt-1 overflow-hidden rounded-md border bg-muted/30 text-sm">
|
|
380
|
+
{rows.map(({ col, value, lineItems }) => {
|
|
381
|
+
const label = t(col.label, { defaultValue: col.label })
|
|
382
|
+
if (lineItems) {
|
|
383
|
+
return (
|
|
384
|
+
<div key={col.key} className="border-t px-3 py-2 first:border-t-0">
|
|
385
|
+
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
386
|
+
{label}
|
|
387
|
+
</div>
|
|
388
|
+
<ViewValue field={col as any} value={value} record={record} />
|
|
389
|
+
</div>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
return (
|
|
393
|
+
<div key={col.key} className="flex items-baseline gap-3 border-t px-3 py-1.5 first:border-t-0">
|
|
394
|
+
<span className="w-1/3 shrink-0 truncate text-muted-foreground">{label}</span>
|
|
395
|
+
<div className="min-w-0 flex-1 [&_p]:py-0">
|
|
396
|
+
<ViewValue field={col as any} value={value} record={record} />
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
)
|
|
400
|
+
})}
|
|
401
|
+
</div>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
231
405
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
232
406
|
const { t } = useTranslation()
|
|
233
407
|
const api = useApi()
|
|
@@ -268,6 +442,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
268
442
|
{action.confirmMessage || `${label}?`}
|
|
269
443
|
</AlertDialogDescription>
|
|
270
444
|
</AlertDialogHeader>
|
|
445
|
+
{record ? <RecordPreview model={model} record={record} /> : null}
|
|
271
446
|
<AlertDialogFooter>
|
|
272
447
|
<AlertDialogCancel disabled={executing}>{t('common.cancel')}</AlertDialogCancel>
|
|
273
448
|
<AlertDialogAction
|
|
@@ -267,7 +267,7 @@ function resolvePath(obj: any, path: string): any {
|
|
|
267
267
|
// objectLabel pulls a human label off a resolved relation/user object the
|
|
268
268
|
// backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
|
|
269
269
|
// as created_by), or `{title}`. Returns undefined for plain/empty objects.
|
|
270
|
-
function objectLabel(value: any): string | undefined {
|
|
270
|
+
export function objectLabel(value: any): string | undefined {
|
|
271
271
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
272
272
|
const label = value.label ?? value.name ?? value.title
|
|
273
273
|
if (label != null && label !== '') return String(label)
|
|
@@ -285,7 +285,7 @@ function pickImage(value: any): string | undefined {
|
|
|
285
285
|
// FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
|
|
286
286
|
// `record.category = {value,label,image?}` (or a bare string/{name}); returns
|
|
287
287
|
// the raw sibling (object or string) so the caller can extract label + image.
|
|
288
|
-
function relationSiblingValue(field: FieldDef, record: any): any {
|
|
288
|
+
export function relationSiblingValue(field: FieldDef, record: any): any {
|
|
289
289
|
if (!record) return undefined
|
|
290
290
|
const candidates: string[] = []
|
|
291
291
|
const ref = getFieldRef(field as ActionFieldDef)
|
|
@@ -305,7 +305,7 @@ function relationSiblingValue(field: FieldDef, record: any): any {
|
|
|
305
305
|
|
|
306
306
|
// fieldItemFields reads the declared jsonb line-items schema off a field,
|
|
307
307
|
// tolerating the snake_case `item_fields` alias the kernel serves.
|
|
308
|
-
function fieldItemFields(field: FieldDef): ItemField[] | undefined {
|
|
308
|
+
export function fieldItemFields(field: FieldDef): ItemField[] | undefined {
|
|
309
309
|
return field.itemFields ?? field.item_fields
|
|
310
310
|
}
|
|
311
311
|
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -77,6 +77,58 @@ import { useDynamicRowActions } from './dynamic-row-actions'
|
|
|
77
77
|
import { ExportDialog } from './dialogs/export'
|
|
78
78
|
import { ImportDialog } from './dialogs/import'
|
|
79
79
|
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Row-data cache (perceived performance).
|
|
82
|
+
//
|
|
83
|
+
// The table fetches rows into local state, so a full page reload starts empty
|
|
84
|
+
// and shows a full skeleton until `/data/:model` resolves — even for a view the
|
|
85
|
+
// user just looked at. We stash the last first-page result and seed the initial
|
|
86
|
+
// state from it so a reload paints the previous rows instantly and the fetch
|
|
87
|
+
// below revalidates in the background (stale-while-revalidate).
|
|
88
|
+
//
|
|
89
|
+
// Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
|
|
90
|
+
// so it must not outlive the tab session (a browser restart or a different login
|
|
91
|
+
// starts clean). The key includes model+endpoint+branch+URL params so a
|
|
92
|
+
// different filter / sort / page never paints the wrong rows. Only the first
|
|
93
|
+
// page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
|
|
94
|
+
const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1'
|
|
95
|
+
interface TableDataCacheEntry { rows: any[]; rowCount: number; ts: number }
|
|
96
|
+
|
|
97
|
+
function tableDataCacheKey(
|
|
98
|
+
model: string | undefined,
|
|
99
|
+
endpoint: string | undefined,
|
|
100
|
+
branchId: string | number | null | undefined,
|
|
101
|
+
search: string,
|
|
102
|
+
): string {
|
|
103
|
+
const p = new URLSearchParams(search)
|
|
104
|
+
const parts: string[] = []
|
|
105
|
+
p.forEach((v, k) => parts.push(`${k}=${v}`))
|
|
106
|
+
parts.sort()
|
|
107
|
+
return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readTableDataCache(key: string): TableDataCacheEntry | null {
|
|
111
|
+
try {
|
|
112
|
+
const raw = sessionStorage.getItem(key)
|
|
113
|
+
if (!raw) return null
|
|
114
|
+
const parsed = JSON.parse(raw)
|
|
115
|
+
return parsed && Array.isArray(parsed.rows) ? parsed : null
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeTableDataCache(key: string, rows: any[], rowCount: number): void {
|
|
122
|
+
try {
|
|
123
|
+
sessionStorage.setItem(
|
|
124
|
+
key,
|
|
125
|
+
JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }),
|
|
126
|
+
)
|
|
127
|
+
} catch {
|
|
128
|
+
// quota / private mode — the cache is a nicety, never fatal
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
80
132
|
export interface DynamicTableProps {
|
|
81
133
|
model: string
|
|
82
134
|
endpoint?: string
|
|
@@ -148,12 +200,27 @@ export function DynamicTable({
|
|
|
148
200
|
const cachedMeta = getMetadata(model)
|
|
149
201
|
|
|
150
202
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
151
|
-
|
|
203
|
+
// Read the row-data cache ONCE at mount (before the fetch effects run) so the
|
|
204
|
+
// first paint uses the previous rows for this exact view instead of skeletons.
|
|
205
|
+
const bootDataKey = tableDataCacheKey(
|
|
206
|
+
model,
|
|
207
|
+
endpoint,
|
|
208
|
+
currentBranch?.id,
|
|
209
|
+
typeof window !== 'undefined' ? window.location.search : '',
|
|
210
|
+
)
|
|
211
|
+
const bootDataRef = useRef<TableDataCacheEntry | null | undefined>(undefined)
|
|
212
|
+
if (bootDataRef.current === undefined) {
|
|
213
|
+
bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null
|
|
214
|
+
}
|
|
215
|
+
const bootData = bootDataRef.current
|
|
216
|
+
const [data, setData] = useState<any[]>(bootData?.rows ?? [])
|
|
152
217
|
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
153
218
|
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
154
219
|
const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
|
|
155
220
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
156
|
-
|
|
221
|
+
// Cached rows → no full-table skeleton on reload; the background fetch still
|
|
222
|
+
// runs and swaps in fresh data.
|
|
223
|
+
const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length))
|
|
157
224
|
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
158
225
|
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
159
226
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
@@ -182,7 +249,7 @@ export function DynamicTable({
|
|
|
182
249
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
183
250
|
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
|
|
184
251
|
const [globalFilter, setGlobalFilter] = useState('')
|
|
185
|
-
const [rowCount, setRowCount] = useState(0)
|
|
252
|
+
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
|
|
186
253
|
|
|
187
254
|
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
|
|
188
255
|
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
@@ -557,15 +624,25 @@ export function DynamicTable({
|
|
|
557
624
|
}
|
|
558
625
|
const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
|
|
559
626
|
if (res.data.success) {
|
|
560
|
-
|
|
627
|
+
const rows = res.data.data || []
|
|
628
|
+
setData(rows)
|
|
561
629
|
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
630
|
+
// Cache the first page for an instant reload paint (see the cache
|
|
631
|
+
// helpers). Keyed off the live URL so it matches the next mount.
|
|
632
|
+
if (enableUrlSync && pagination.pageIndex === 0) {
|
|
633
|
+
writeTableDataCache(
|
|
634
|
+
tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
|
|
635
|
+
rows,
|
|
636
|
+
res.data.meta?.total ?? rows.length,
|
|
637
|
+
)
|
|
638
|
+
}
|
|
562
639
|
}
|
|
563
640
|
} catch (error) {
|
|
564
641
|
console.error('Error al cargar los datos', error)
|
|
565
642
|
} finally {
|
|
566
643
|
setLoadingData(false)
|
|
567
644
|
}
|
|
568
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api])
|
|
645
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
|
|
569
646
|
|
|
570
647
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
571
648
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -613,6 +690,14 @@ export function DynamicTable({
|
|
|
613
690
|
const rows = res.data.data || []
|
|
614
691
|
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
615
692
|
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
693
|
+
// Cache the first (replace) page for an instant reload paint.
|
|
694
|
+
if (!append && enableUrlSync) {
|
|
695
|
+
writeTableDataCache(
|
|
696
|
+
tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
|
|
697
|
+
rows,
|
|
698
|
+
res.data.meta?.total ?? rows.length,
|
|
699
|
+
)
|
|
700
|
+
}
|
|
616
701
|
// A short page means the backend has no more rows, even if
|
|
617
702
|
// meta.total disagrees with the visible count (count query
|
|
618
703
|
// vs list query drift, dedupe). Without this the sentinel
|
|
@@ -626,7 +711,7 @@ export function DynamicTable({
|
|
|
626
711
|
else setLoadingData(false)
|
|
627
712
|
}
|
|
628
713
|
},
|
|
629
|
-
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
|
|
714
|
+
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
|
|
630
715
|
)
|
|
631
716
|
|
|
632
717
|
// Signature of everything that must reset the incremental list to page 1:
|