@asteby/metacore-runtime-react 24.0.0 → 24.0.2

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,35 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 24.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - a27d1a2: feat(relations): secondary identifier (SKU/email) under a reference chip
8
+
9
+ Reference chips (resolved FK cells, endpoint-option badges, jsonb line-item refs,
10
+ confirm-modal items) now render a muted secondary line under the label — a
11
+ product's SKU, a user's email — so a resolved record reads "Name / SKU" instead
12
+ of a bare name. Declarative: the SDK reads the backend-projected `subtitle` /
13
+ `description` on the option or resolved sibling (never a hardcoded column). Absent
14
+ → single-line as before. The select picker already surfaced `description`; this
15
+ brings the resolved-chip surfaces in line.
16
+
17
+ ## 24.0.1
18
+
19
+ ### Patch Changes
20
+
21
+ - 4a62c3c: Preview automático del registro en el modal de confirmación de acciones de fila.
22
+
23
+ Al confirmar una row-action con `confirm` (p. ej. aceptar/rechazar un traspaso), el
24
+ `ConfirmActionDialog` ahora muestra un resumen compacto y de solo lectura del registro
25
+ que va a afectar, para no confirmar a ciegas. Es 100% del SDK y genérico: se apoya en la
26
+ metadata de tabla del modelo (labels + display hints, leída del cache o con un único fetch
27
+ a `/metadata/table/<model>`) y en los siblings de relación que la tabla ya resolvió sobre
28
+ la fila. La heurística surface relaciones resueltas a su label, campos line-items (jsonb,
29
+ como `Transfer.items`, renderizados producto × cantidad) y un puñado de escalares de
30
+ identidad; omite `id`, `organization_id`, timestamps y los `*_id` crudos sin label. Se
31
+ degrada solo: si no hay nada útil que mostrar, no renderiza la sección.
32
+
3
33
  ## 24.0.0
4
34
 
5
35
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AAgDA,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"}
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();
@@ -1 +1 @@
1
- {"version":3,"file":"collection-cell.d.ts","sourceRoot":"","sources":["../src/collection-cell.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkD9B,sFAAsF;AACtF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,CAAA;AAE9D;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACtB,qEAAqE;IACrE,GAAG,EAAE,MAAM,CAAA;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAA;CACf;AAoND;;;;;;GAMG;AACH,wBAAgB,WAAW,CACvB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAWR;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACtB,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAcR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYnD;AAiOD,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,OAAO,CAAA;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,CAAC,CAAC,EAAE,SAAS,CAAA;IACb;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC/B;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,SAAa,EACb,MAAM,EACN,CAAC,EACD,UAAU,EACV,WAAW,EACX,OAAiB,GACpB,EAAE,mBAAmB,qBA2HrB"}
1
+ {"version":3,"file":"collection-cell.d.ts","sourceRoot":"","sources":["../src/collection-cell.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAkD9B,sFAAsF;AACtF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,CAAA;AAE9D;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACtB,qEAAqE;IACrE,GAAG,EAAE,MAAM,CAAA;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAA;CACf;AAwOD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACvB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAWR;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACtB,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAcR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYnD;AAkOD,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,OAAO,CAAA;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,CAAC,CAAC,EAAE,SAAS,CAAA;IACb;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC/B;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,SAAa,EACb,MAAM,EACN,CAAC,EACD,UAAU,EACV,WAAW,EACX,OAAiB,GACpB,EAAE,mBAAmB,qBA2HrB"}
@@ -55,12 +55,16 @@ function resolvedRefFor(field, row) {
55
55
  const obj = sibling;
56
56
  const label = obj.label;
57
57
  if (label !== undefined && label !== null && label !== '') {
58
+ const sub = obj.subtitle ?? obj.description;
58
59
  return {
59
60
  label: String(label),
60
61
  value: obj.value,
61
62
  image: typeof obj.image === 'string' && obj.image !== ''
62
63
  ? obj.image
63
64
  : undefined,
65
+ subtitle: sub !== undefined && sub !== null && sub !== ''
66
+ ? String(sub)
67
+ : undefined,
64
68
  };
65
69
  }
66
70
  }
@@ -92,10 +96,12 @@ function resolvedRefObject(v) {
92
96
  const label = v.label;
93
97
  if (label === undefined || label === null || label === '')
94
98
  return null;
99
+ const sub = v.subtitle ?? v.description;
95
100
  return {
96
101
  label: String(label),
97
102
  value: v.value,
98
103
  image: typeof v.image === 'string' && v.image !== '' ? v.image : undefined,
104
+ subtitle: sub !== undefined && sub !== null && sub !== '' ? String(sub) : undefined,
99
105
  };
100
106
  }
101
107
  /**
@@ -104,9 +110,10 @@ function resolvedRefObject(v) {
104
110
  * resolved name. The exact look the FK table columns use, so resolved relations
105
111
  * read consistently everywhere (table popover, detail view, edit, line items).
106
112
  */
107
- function RefChip({ label, image, getImageUrl, }) {
113
+ function RefChip({ label, image, subtitle, getImageUrl, }) {
108
114
  const isDark = useIsDarkTheme();
109
- return (_jsxs("span", { className: "inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium", style: relationChipStyles(label, { isDark }), title: label, children: [image ? (_jsxs(Avatar, { className: "shrink-0 rounded-sm ring-1 ring-border/40", style: { width: 18, height: 18 }, children: [_jsx(AvatarImage, { src: getImageUrl ? getImageUrl(image) : image, alt: label, className: "object-cover" }), _jsx(AvatarFallback, { className: "rounded-sm bg-primary/10 text-[8px] font-bold text-primary", children: getInitials(label) })] })) : (_jsx(Box, { className: "h-3 w-3 shrink-0 opacity-70" })), _jsx("span", { className: "truncate", children: label })] }));
115
+ const size = subtitle ? 24 : 18;
116
+ return (_jsxs("span", { className: "inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium", style: relationChipStyles(label, { isDark }), title: subtitle ? `${label} · ${subtitle}` : label, children: [image ? (_jsxs(Avatar, { className: "shrink-0 rounded-sm ring-1 ring-border/40", style: { width: size, height: size }, children: [_jsx(AvatarImage, { src: getImageUrl ? getImageUrl(image) : image, alt: label, className: "object-cover" }), _jsx(AvatarFallback, { className: "rounded-sm bg-primary/10 text-[8px] font-bold text-primary", children: getInitials(label) })] })) : (_jsx(Box, { className: "h-3 w-3 shrink-0 opacity-70" })), subtitle ? (_jsxs("span", { className: "flex flex-col leading-tight min-w-0", children: [_jsx("span", { className: "truncate", children: label }), _jsx("span", { className: "truncate text-[0.65rem] opacity-70", children: subtitle })] })) : (_jsx("span", { className: "truncate", children: label }))] }));
110
117
  }
111
118
  /**
112
119
  * Visual cell for one declared item-field. A resolved `ref` renders as a
@@ -117,7 +124,7 @@ function RefChip({ label, image, getImageUrl, }) {
117
124
  function ItemFieldCell({ field, row, getImageUrl, }) {
118
125
  const ref = resolvedRefFor(field, row);
119
126
  if (ref?.label) {
120
- return (_jsx(RefChip, { label: ref.label, image: ref.image, getImageUrl: getImageUrl }));
127
+ return (_jsx(RefChip, { label: ref.label, image: ref.image, subtitle: ref.subtitle, getImageUrl: getImageUrl }));
121
128
  }
122
129
  return _jsx(_Fragment, { children: formatScalar(row[field.key]) });
123
130
  }
@@ -311,7 +318,7 @@ function MiniTable({ rows, locale, t, itemFields, getImageUrl, }) {
311
318
  const ref = refKeys.has(key)
312
319
  ? resolvedRefObject(row[key])
313
320
  : null;
314
- return (_jsx(TableCell, { className: "text-xs whitespace-nowrap", children: ref ? (_jsx(RefChip, { label: ref.label, image: ref.image, getImageUrl: getImageUrl })) : (formatScalar(row[key])) }, key));
321
+ return (_jsx(TableCell, { className: "text-xs whitespace-nowrap", children: ref ? (_jsx(RefChip, { label: ref.label, image: ref.image, subtitle: ref.subtitle, getImageUrl: getImageUrl })) : (formatScalar(row[key])) }, key));
315
322
  }) }, i))) })] }));
316
323
  }
317
324
  function ScalarList({ values }) {
@@ -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
@@ -39,6 +39,15 @@ export interface DisplayOption {
39
39
  icon?: string;
40
40
  color?: string;
41
41
  image?: string;
42
+ /**
43
+ * Secondary identifier shown UNDER the label (muted, smaller) — a product's
44
+ * SKU/barcode, a user's email, etc. Lets a reference chip read like
45
+ * "Camiseta / SKU-001" instead of a bare name, so a resolved record is
46
+ * unambiguous. Populated declaratively: the backend projects it as the
47
+ * option/relation `description` (SearchConfig/FieldOptionsConfig.Description,
48
+ * or a column's `label_description`). Absent → single-line label as before.
49
+ */
50
+ subtitle?: string;
42
51
  }
43
52
  /**
44
53
  * Colored option badge — the canonical "pro" pill for a select/status/badge
@@ -1 +1 @@
1
- {"version":3,"file":"display-value.d.ts","sourceRoot":"","sources":["../src/display-value.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AASzB;;;GAGG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAmBxC;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAepD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC;IACrC,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,CAcA,CAAA;AAED,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC;IAC/B,MAAM,EAAE,aAAa,CAAA;IACrB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,CAmBA,CAAA"}
1
+ {"version":3,"file":"display-value.d.ts","sourceRoot":"","sources":["../src/display-value.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,MAAM,OAAO,CAAA;AASzB;;;GAGG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAmBxC;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAepD;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC;IACrC,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,CAcA,CAAA;AAED,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC;IAC/B,MAAM,EAAE,aAAa,CAAA;IACrB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,CA8BA,CAAA"}
@@ -70,5 +70,5 @@ export const OptionBadge = ({ option, getImageUrl }) => {
70
70
  const isDark = useIsDarkTheme();
71
71
  const colorSource = option.color || optionColor(option.value || option.label);
72
72
  const colorStyles = generateBadgeStyles(colorSource, { isDark });
73
- return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.image ? (_jsx(RelationThumbnail, { src: option.image, alt: option.label, getImageUrl: getImageUrl, size: 16 })) : (option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" })), _jsx("span", { children: option.label })] }));
73
+ return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.image ? (_jsx(RelationThumbnail, { src: option.image, alt: option.label, getImageUrl: getImageUrl, size: option.subtitle ? 22 : 16 })) : (option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" })), option.subtitle ? (_jsxs("span", { className: "flex flex-col leading-tight text-start", children: [_jsx("span", { children: option.label }), _jsx("span", { className: "text-[0.7rem] opacity-70", children: option.subtitle })] })) : (_jsx("span", { children: option.label }))] }));
74
74
  };
@@ -112,6 +112,15 @@ export declare const resolveRelationLabel: (col: ColumnDefinition, row: any) =>
112
112
  * the chip then renders text-only, exactly as before.
113
113
  */
114
114
  export declare const resolveRelationImage: (col: ColumnDefinition, row: any) => string;
115
+ /**
116
+ * Reads a secondary identifier the backend stamps on a resolved FK sibling — a
117
+ * product's SKU, a user's email — projected as `subtitle`/`description` (the
118
+ * relational twin of `image` via the column's `label_description`). Rendered
119
+ * muted under the label so a reference chip reads "Name / SKU". Domain-agnostic:
120
+ * only the generic `subtitle`/`description` keys are read (never a hardcoded
121
+ * `sku`/`code`), so the author picks the column declaratively. '' when absent.
122
+ */
123
+ export declare const resolveRelationSubtitle: (col: ColumnDefinition, row: any) => string;
115
124
  /**
116
125
  * Builds the canonical column factory used by `<DynamicTable>` when the host
117
126
  * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
@@ -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;AAuC9C,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;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAqFhF;;;;;;;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,CAqmBnB;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;AAuC9C,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;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAwFhF;;;;;;;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;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOzE,CAAA;AA8ID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAqmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -213,8 +213,11 @@ const BadgeWithEndpointOptions = ({ endpoint, value, getImageUrl }) => {
213
213
  const { optionsMap } = React.useContext(OptionsContext);
214
214
  const options = optionsMap.get(endpoint) || [];
215
215
  const option = options.find((opt) => opt.value === value);
216
+ // Reference options carry the backend-projected `description` (the SKU/email
217
+ // the author pointed the options config at) — surface it as the chip subtitle
218
+ // so a resolved record reads "Name / SKU" instead of a bare name.
216
219
  if (option)
217
- return _jsx(OptionBadge, { option: option, fallback: String(value), getImageUrl: getImageUrl });
220
+ return _jsx(OptionBadge, { option: { ...option, subtitle: option.subtitle ?? option.description }, fallback: String(value), getImageUrl: getImageUrl });
218
221
  // No declared option matched → humanize the raw token as a safety net so a
219
222
  // cell never shows `in_progress` verbatim (option.label still wins above).
220
223
  return _jsx(Badge, { variant: "outline", children: humanizeToken(value) });
@@ -336,6 +339,23 @@ export const resolveRelationImage = (col, row) => {
336
339
  }
337
340
  return '';
338
341
  };
342
+ /**
343
+ * Reads a secondary identifier the backend stamps on a resolved FK sibling — a
344
+ * product's SKU, a user's email — projected as `subtitle`/`description` (the
345
+ * relational twin of `image` via the column's `label_description`). Rendered
346
+ * muted under the label so a reference chip reads "Name / SKU". Domain-agnostic:
347
+ * only the generic `subtitle`/`description` keys are read (never a hardcoded
348
+ * `sku`/`code`), so the author picks the column declaratively. '' when absent.
349
+ */
350
+ export const resolveRelationSubtitle = (col, row) => {
351
+ const sibling = getNestedValue(row, relationKeyFor(col));
352
+ if (sibling && typeof sibling === 'object') {
353
+ const sub = sibling.subtitle ?? sibling.description;
354
+ if (sub !== undefined && sub !== null && sub !== '')
355
+ return String(sub);
356
+ }
357
+ return '';
358
+ };
339
359
  /**
340
360
  * Renders a resolved FK relation as a clean, truncated chip. Reads the
341
361
  * backend-resolved sibling `{ value, label[, image] }` (see `relationKeyFor`)
@@ -350,12 +370,13 @@ const RelationCell = ({ col, row, getImageUrl }) => {
350
370
  if (!display)
351
371
  return _jsx(EmptyCell, {});
352
372
  const image = resolveRelationImage(col, row);
373
+ const subtitle = resolveRelationSubtitle(col, row);
353
374
  // Deterministic, SUBTLE color keyed on the resolved label — lighter than
354
375
  // enum badges (soft tint, no heavy fill) so category/brand chips read as
355
376
  // alive yet stay visually distinct from option/status badges. Inline style
356
377
  // (hex-derived) bypasses the host tailwind safelist.
357
378
  const chipStyles = relationChipStyles(display, { isDark });
358
- 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 })] }));
379
+ 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: subtitle ? `${display} · ${subtitle}` : display, children: [image && (_jsx(RelationThumbnail, { src: image, alt: display, getImageUrl: getImageUrl, size: subtitle ? 24 : 18 })), subtitle ? (_jsxs("span", { className: "flex flex-col leading-tight min-w-0", children: [_jsx("span", { className: "truncate", children: display }), _jsx("span", { className: "truncate text-[0.7rem] opacity-70", children: subtitle })] })) : (_jsx("span", { className: "truncate", children: display }))] }));
359
380
  };
360
381
  /**
361
382
  * Renders a SAP-style polymorphic source-document reference as a navigable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "24.0.0",
3
+ "version": "24.0.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,8 +37,8 @@
37
37
  "react-i18next": ">=13",
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
- "@asteby/metacore-sdk": "^3.2.0",
41
- "@asteby/metacore-ui": "^2.10.0"
40
+ "@asteby/metacore-sdk": "^3.2.1",
41
+ "@asteby/metacore-ui": "^2.10.1"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@tanstack/react-router": {
@@ -67,8 +67,8 @@
67
67
  "typescript": "^6.0.0",
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
- "@asteby/metacore-sdk": "3.2.0",
71
- "@asteby/metacore-ui": "2.10.0"
70
+ "@asteby/metacore-sdk": "3.2.1",
71
+ "@asteby/metacore-ui": "2.10.1"
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 type { ActionFieldDef } from './types'
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
@@ -92,6 +92,8 @@ interface ResolvedRef {
92
92
  value?: unknown
93
93
  /** Optional thumbnail (product photo, logo, avatar) resolved by the backend. */
94
94
  image?: string
95
+ /** Optional secondary identifier (SKU/email) shown muted under the label. */
96
+ subtitle?: string
95
97
  }
96
98
 
97
99
  /**
@@ -110,6 +112,7 @@ function resolvedRefFor(
110
112
  const obj = sibling as Record<string, unknown>
111
113
  const label = obj.label
112
114
  if (label !== undefined && label !== null && label !== '') {
115
+ const sub = obj.subtitle ?? obj.description
113
116
  return {
114
117
  label: String(label),
115
118
  value: obj.value,
@@ -117,6 +120,10 @@ function resolvedRefFor(
117
120
  typeof obj.image === 'string' && obj.image !== ''
118
121
  ? obj.image
119
122
  : undefined,
123
+ subtitle:
124
+ sub !== undefined && sub !== null && sub !== ''
125
+ ? String(sub)
126
+ : undefined,
120
127
  }
121
128
  }
122
129
  } else if (typeof sibling === 'string' && sibling !== '') {
@@ -148,11 +155,14 @@ function resolvedRefObject(
148
155
  if (!isPlainObject(v)) return null
149
156
  const label = v.label
150
157
  if (label === undefined || label === null || label === '') return null
158
+ const sub = v.subtitle ?? v.description
151
159
  return {
152
160
  label: String(label),
153
161
  value: v.value,
154
162
  image:
155
163
  typeof v.image === 'string' && v.image !== '' ? v.image : undefined,
164
+ subtitle:
165
+ sub !== undefined && sub !== null && sub !== '' ? String(sub) : undefined,
156
166
  }
157
167
  }
158
168
 
@@ -165,23 +175,26 @@ function resolvedRefObject(
165
175
  function RefChip({
166
176
  label,
167
177
  image,
178
+ subtitle,
168
179
  getImageUrl,
169
180
  }: {
170
181
  label: string
171
182
  image?: string
183
+ subtitle?: string
172
184
  getImageUrl?: (path: string) => string
173
185
  }): React.ReactElement {
174
186
  const isDark = useIsDarkTheme()
187
+ const size = subtitle ? 24 : 18
175
188
  return (
176
189
  <span
177
190
  className="inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
178
191
  style={relationChipStyles(label, { isDark })}
179
- title={label}
192
+ title={subtitle ? `${label} · ${subtitle}` : label}
180
193
  >
181
194
  {image ? (
182
195
  <Avatar
183
196
  className="shrink-0 rounded-sm ring-1 ring-border/40"
184
- style={{ width: 18, height: 18 }}
197
+ style={{ width: size, height: size }}
185
198
  >
186
199
  <AvatarImage
187
200
  src={getImageUrl ? getImageUrl(image) : image}
@@ -195,7 +208,14 @@ function RefChip({
195
208
  ) : (
196
209
  <Box className="h-3 w-3 shrink-0 opacity-70" />
197
210
  )}
198
- <span className="truncate">{label}</span>
211
+ {subtitle ? (
212
+ <span className="flex flex-col leading-tight min-w-0">
213
+ <span className="truncate">{label}</span>
214
+ <span className="truncate text-[0.65rem] opacity-70">{subtitle}</span>
215
+ </span>
216
+ ) : (
217
+ <span className="truncate">{label}</span>
218
+ )}
199
219
  </span>
200
220
  )
201
221
  }
@@ -218,7 +238,7 @@ function ItemFieldCell({
218
238
  const ref = resolvedRefFor(field, row)
219
239
  if (ref?.label) {
220
240
  return (
221
- <RefChip label={ref.label} image={ref.image} getImageUrl={getImageUrl} />
241
+ <RefChip label={ref.label} image={ref.image} subtitle={ref.subtitle} getImageUrl={getImageUrl} />
222
242
  )
223
243
  }
224
244
  return <>{formatScalar(row[field.key])}</>
@@ -495,6 +515,7 @@ function MiniTable({
495
515
  <RefChip
496
516
  label={ref.label}
497
517
  image={ref.image}
518
+ subtitle={ref.subtitle}
498
519
  getImageUrl={getImageUrl}
499
520
  />
500
521
  ) : (
@@ -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
 
@@ -98,6 +98,15 @@ export interface DisplayOption {
98
98
  icon?: string
99
99
  color?: string
100
100
  image?: string
101
+ /**
102
+ * Secondary identifier shown UNDER the label (muted, smaller) — a product's
103
+ * SKU/barcode, a user's email, etc. Lets a reference chip read like
104
+ * "Camiseta / SKU-001" instead of a bare name, so a resolved record is
105
+ * unambiguous. Populated declaratively: the backend projects it as the
106
+ * option/relation `description` (SearchConfig/FieldOptionsConfig.Description,
107
+ * or a column's `label_description`). Absent → single-line label as before.
108
+ */
109
+ subtitle?: string
101
110
  }
102
111
 
103
112
  /**
@@ -123,12 +132,23 @@ export const OptionBadge: React.FC<{
123
132
  src={option.image}
124
133
  alt={option.label}
125
134
  getImageUrl={getImageUrl}
126
- size={16}
135
+ size={option.subtitle ? 22 : 16}
127
136
  />
128
137
  ) : (
129
138
  option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
130
139
  )}
131
- <span>{option.label}</span>
140
+ {option.subtitle ? (
141
+ // Two-line identity: label on top, the secondary identifier
142
+ // (SKU/email/…) muted underneath. `leading-tight` keeps the pill
143
+ // compact; the subtitle inherits the badge's fg at reduced opacity
144
+ // so it reads as secondary on any color.
145
+ <span className="flex flex-col leading-tight text-start">
146
+ <span>{option.label}</span>
147
+ <span className="text-[0.7rem] opacity-70">{option.subtitle}</span>
148
+ </span>
149
+ ) : (
150
+ <span>{option.label}</span>
151
+ )}
132
152
  </Badge>
133
153
  )
134
154
  }
@@ -332,7 +332,10 @@ const BadgeWithEndpointOptions: React.FC<{
332
332
  const { optionsMap } = React.useContext(OptionsContext)
333
333
  const options = optionsMap.get(endpoint) || []
334
334
  const option = options.find((opt: any) => opt.value === value)
335
- if (option) return <OptionBadge option={option} fallback={String(value)} getImageUrl={getImageUrl} />
335
+ // Reference options carry the backend-projected `description` (the SKU/email
336
+ // the author pointed the options config at) — surface it as the chip subtitle
337
+ // so a resolved record reads "Name / SKU" instead of a bare name.
338
+ if (option) return <OptionBadge option={{ ...option, subtitle: option.subtitle ?? (option as any).description }} fallback={String(value)} getImageUrl={getImageUrl} />
336
339
  // No declared option matched → humanize the raw token as a safety net so a
337
340
  // cell never shows `in_progress` verbatim (option.label still wins above).
338
341
  return <Badge variant="outline">{humanizeToken(value)}</Badge>
@@ -461,6 +464,23 @@ export const resolveRelationImage = (col: ColumnDefinition, row: any): string =>
461
464
  return ''
462
465
  }
463
466
 
467
+ /**
468
+ * Reads a secondary identifier the backend stamps on a resolved FK sibling — a
469
+ * product's SKU, a user's email — projected as `subtitle`/`description` (the
470
+ * relational twin of `image` via the column's `label_description`). Rendered
471
+ * muted under the label so a reference chip reads "Name / SKU". Domain-agnostic:
472
+ * only the generic `subtitle`/`description` keys are read (never a hardcoded
473
+ * `sku`/`code`), so the author picks the column declaratively. '' when absent.
474
+ */
475
+ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string => {
476
+ const sibling = getNestedValue(row, relationKeyFor(col))
477
+ if (sibling && typeof sibling === 'object') {
478
+ const sub = sibling.subtitle ?? sibling.description
479
+ if (sub !== undefined && sub !== null && sub !== '') return String(sub)
480
+ }
481
+ return ''
482
+ }
483
+
464
484
  /**
465
485
  * Renders a resolved FK relation as a clean, truncated chip. Reads the
466
486
  * backend-resolved sibling `{ value, label[, image] }` (see `relationKeyFor`)
@@ -478,6 +498,7 @@ const RelationCell: React.FC<{
478
498
  const display = resolveRelationLabel(col, row)
479
499
  if (!display) return <EmptyCell />
480
500
  const image = resolveRelationImage(col, row)
501
+ const subtitle = resolveRelationSubtitle(col, row)
481
502
  // Deterministic, SUBTLE color keyed on the resolved label — lighter than
482
503
  // enum badges (soft tint, no heavy fill) so category/brand chips read as
483
504
  // alive yet stay visually distinct from option/status badges. Inline style
@@ -487,12 +508,19 @@ const RelationCell: React.FC<{
487
508
  <span
488
509
  className="inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-medium"
489
510
  style={chipStyles}
490
- title={display}
511
+ title={subtitle ? `${display} · ${subtitle}` : display}
491
512
  >
492
513
  {image && (
493
- <RelationThumbnail src={image} alt={display} getImageUrl={getImageUrl} size={18} />
514
+ <RelationThumbnail src={image} alt={display} getImageUrl={getImageUrl} size={subtitle ? 24 : 18} />
515
+ )}
516
+ {subtitle ? (
517
+ <span className="flex flex-col leading-tight min-w-0">
518
+ <span className="truncate">{display}</span>
519
+ <span className="truncate text-[0.7rem] opacity-70">{subtitle}</span>
520
+ </span>
521
+ ) : (
522
+ <span className="truncate">{display}</span>
494
523
  )}
495
- <span className="truncate">{display}</span>
496
524
  </span>
497
525
  )
498
526
  }