@asteby/metacore-runtime-react 18.1.0 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // DynamicRecordDialog — renders a create/edit/view modal for a model based
3
- // on metadata fetched from `/metadata/modal/:model`. Ported from the ops
4
- // starter. Host-owned infra that was referenced by alias (axios client,
5
- // branch store) now flows through <ApiProvider> from runtime-react.
3
+ // on metadata fetched from `/metadata/modal/:model`. This is the single,
4
+ // SDK-owned source of truth for declarative record rendering (the ops fork was
5
+ // consolidated back into here): tz-aware dates, FK image/label leads in both
6
+ // view and edit, resolved relation/user-object labels (never raw JSON), nil-UUID
7
+ // elision, pro option color/icon badges, and one_to_many child panels.
8
+ //
9
+ // Host-owned infra that was referenced by alias (axios client, branch store)
10
+ // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
11
+ // the image-url resolver and the org IANA timezone — are passed as props so the
12
+ // SDK stays transport- and host-agnostic.
6
13
  import { createContext, useContext, useEffect, useRef, useState } from 'react';
14
+ import { useTranslation } from 'react-i18next';
7
15
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, Textarea, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Skeleton, Badge, Popover, PopoverContent, PopoverTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@asteby/metacore-ui/primitives';
8
16
  import { cn } from '@asteby/metacore-ui/lib';
9
17
  import { Calendar } from './_primitives';
@@ -12,30 +20,103 @@ import { format, parseISO } from 'date-fns';
12
20
  import { es } from 'date-fns/locale';
13
21
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react';
14
22
  import { useApi } from '../api-context';
15
- import { DynamicSelectField } from '../dynamic-select-field';
23
+ import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
24
+ import { DynamicRelations } from '../dynamic-relations';
25
+ import { useOptionsResolver } from '../use-options-resolver';
16
26
  import { getFieldRef } from '../dynamic-form-schema';
17
- import { normalizeNilUuid } from '../nil-uuid';
27
+ import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
18
28
  import { humanizeToken } from '../dynamic-columns-helpers';
29
+ import { formatDateCell } from '../dynamic-columns';
30
+ import { ImageUrlContext, identityImageUrl } from '../image-url-context';
31
+ // localizedModelName resolves the (possibly addon-i18n) model name: prefer the
32
+ // translated titleKey, fall back to the backend-provided raw title.
33
+ function localizedModelName(meta, t) {
34
+ if (meta.titleKey && t(meta.titleKey) !== meta.titleKey)
35
+ return t(meta.titleKey);
36
+ return meta.title || '';
37
+ }
19
38
  function resolvePath(obj, path) {
20
39
  return path.split('.').reduce((acc, part) => acc?.[part], obj);
21
40
  }
41
+ // objectLabel pulls a human label off a resolved relation/user object the
42
+ // backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
43
+ // as created_by), or `{title}`. Returns undefined for plain/empty objects.
44
+ function objectLabel(value) {
45
+ if (!value || typeof value !== 'object' || Array.isArray(value))
46
+ return undefined;
47
+ const label = value.label ?? value.name ?? value.title;
48
+ if (label != null && label !== '')
49
+ return String(label);
50
+ return undefined;
51
+ }
52
+ // pickImage reads an image-ish path off a resolved object (FK sibling, user).
53
+ function pickImage(value) {
54
+ if (!value || typeof value !== 'object')
55
+ return undefined;
56
+ const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail;
57
+ return typeof img === 'string' && img !== '' ? img : undefined;
58
+ }
59
+ // relationSiblingValue reads the resolved relation the table served alongside an
60
+ // FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
61
+ // `record.category = {value,label,image?}` (or a bare string/{name}); returns
62
+ // the raw sibling (object or string) so the caller can extract label + image.
63
+ function relationSiblingValue(field, record) {
64
+ if (!record)
65
+ return undefined;
66
+ const candidates = [];
67
+ const ref = getFieldRef(field);
68
+ if (ref)
69
+ candidates.push(ref);
70
+ if (typeof field.key === 'string' && field.key.endsWith('_id'))
71
+ candidates.push(field.key.slice(0, -3));
72
+ for (const key of candidates) {
73
+ const sib = record[key];
74
+ if (sib === undefined || sib === null)
75
+ continue;
76
+ if (typeof sib === 'string') {
77
+ if (sib === '' || isNilUuid(sib))
78
+ continue;
79
+ return sib;
80
+ }
81
+ if (typeof sib === 'object')
82
+ return sib;
83
+ }
84
+ return undefined;
85
+ }
86
+ // servedOption matches a field's served option list (enum/select with
87
+ // {value,label,color,icon,image}) against the current value.
88
+ function servedOption(field, value) {
89
+ if (!field.options?.length)
90
+ return undefined;
91
+ return field.options.find(o => o.value === String(value ?? ''));
92
+ }
93
+ // createdBySibling reads the resolver object the backend serves for the
94
+ // auto-injected `created_by` avatar column: {name, avatar, email}.
95
+ function createdBySibling(value, record) {
96
+ const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by;
97
+ if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email))
98
+ return obj;
99
+ return undefined;
100
+ }
101
+ // isRelationField — a field that resolves to another row (so view renders a lead
102
+ // + label and edit renders the searchable picker).
103
+ function isRelationField(field) {
104
+ return (field.type === 'search' ||
105
+ field.type === 'dynamic_select' ||
106
+ field.widget === 'dynamic_select' ||
107
+ !!getFieldRef(field) ||
108
+ !!field.searchEndpoint);
109
+ }
22
110
  function formatDisplayValue(rawValue, field) {
23
111
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
24
112
  const value = normalizeNilUuid(rawValue);
25
113
  if (value === null || value === undefined || value === '')
26
114
  return '—';
115
+ const objLabel = objectLabel(value);
116
+ if (objLabel !== undefined)
117
+ return objLabel;
27
118
  if (field.type === 'boolean' || typeof value === 'boolean')
28
119
  return value ? 'Sí' : 'No';
29
- if (field.type === 'date') {
30
- try {
31
- return new Date(value).toLocaleDateString('es-MX', {
32
- day: 'numeric', month: 'long', year: 'numeric',
33
- });
34
- }
35
- catch {
36
- return String(value);
37
- }
38
- }
39
120
  if (field.type === 'select' && field.options?.length) {
40
121
  const match = field.options.find(o => o.value === String(value));
41
122
  // Matched option label wins (localized); humanize the raw token only
@@ -46,31 +127,42 @@ function formatDisplayValue(rawValue, field) {
46
127
  }
47
128
  const MODE_CONFIG = {
48
129
  create: {
49
- getTitle: (meta) => meta.createTitle || meta.title || 'Nuevo registro',
130
+ getTitle: (meta, t) => {
131
+ const name = localizedModelName(meta, t);
132
+ return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro');
133
+ },
50
134
  description: 'Completa los campos para crear un nuevo registro.',
51
135
  submitLabel: 'Crear',
52
136
  submittingLabel: 'Creando...',
53
137
  cancelLabel: 'Cancelar',
54
138
  },
55
139
  edit: {
56
- getTitle: (meta) => meta.editTitle || meta.title || 'Editar registro',
140
+ getTitle: (meta, t) => {
141
+ const name = localizedModelName(meta, t);
142
+ return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro');
143
+ },
57
144
  description: 'Modifica los campos y guarda los cambios.',
58
145
  submitLabel: 'Guardar cambios',
59
146
  submittingLabel: 'Guardando...',
60
147
  cancelLabel: 'Cancelar',
61
148
  },
62
149
  view: {
63
- getTitle: (meta) => meta.title || 'Ver registro',
150
+ getTitle: (meta, t) => localizedModelName(meta, t) || meta.title || 'Ver registro',
64
151
  description: 'Información detallada del registro.',
65
152
  submitLabel: '',
66
153
  submittingLabel: '',
67
154
  cancelLabel: 'Cerrar',
68
155
  },
69
156
  };
157
+ // Context threading host runtime values to nested field components (uploads,
158
+ // image leads, tz-aware dates) without prop-drilling through every renderer.
70
159
  const ModelContext = createContext('');
71
- export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, }) {
160
+ const TimeZoneContext = createContext(undefined);
161
+ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, }) {
72
162
  const api = useApi();
163
+ const { t } = useTranslation();
73
164
  const [modalMeta, setModalMeta] = useState(schema ? schema : null);
165
+ const [relations, setRelations] = useState([]);
74
166
  const [record, setRecord] = useState(null);
75
167
  const [formValues, setFormValues] = useState({});
76
168
  const [loading, setLoading] = useState(false);
@@ -80,14 +172,39 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
80
172
  const isView = mode === 'view';
81
173
  const isEditable = mode === 'create' || mode === 'edit';
82
174
  const config = MODE_CONFIG[mode];
175
+ // ── Fetch metadata + record when dialog opens ──────────────────────────
83
176
  useEffect(() => {
84
177
  if (!open)
85
178
  return;
86
179
  if (!isCreate && !recordId)
87
180
  return;
88
181
  let cancelled = false;
182
+ // Seed instantly from the row the table already has so view/edit render
183
+ // without a spinner. The list row carries the pro siblings (resolved
184
+ // relation, served options, image url) the table cells used.
185
+ const seed = !isCreate && initialRecord ? initialRecord : null;
186
+ if (seed)
187
+ setRecord(seed);
188
+ const seedForm = (meta, rec) => {
189
+ const initial = {};
190
+ for (const field of meta.fields ?? []) {
191
+ initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? '';
192
+ }
193
+ setFormValues(initial);
194
+ };
195
+ // A field value is "missing" from the seed row when the list omitted that
196
+ // column. Sibling pro fields aren't form fields, so we only check the
197
+ // declared field keys.
198
+ const seedIsComplete = (meta, rec) => (meta.fields ?? []).every(f => {
199
+ if (f.hidden)
200
+ return true;
201
+ const v = resolvePath(rec, f.key);
202
+ return v !== undefined;
203
+ });
89
204
  const load = async () => {
90
- setLoading(true);
205
+ // Only show the skeleton when we have nothing to render yet.
206
+ if (!seed)
207
+ setLoading(true);
91
208
  try {
92
209
  let meta = schema ? schema : null;
93
210
  if (!meta) {
@@ -106,8 +223,14 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
106
223
  : field.defaultValue) ?? '';
107
224
  }
108
225
  setFormValues(initial);
226
+ return;
109
227
  }
110
- else {
228
+ // Render immediately from the seed row.
229
+ if (seed && meta)
230
+ seedForm(meta, seed);
231
+ // Only hit the record endpoint if the seed is absent or missing
232
+ // some declared field — keeps the modal instant for full rows.
233
+ if (!seed || (meta && !seedIsComplete(meta, seed))) {
111
234
  const recordEndpoint = endpoint
112
235
  ? `${endpoint}/${recordId}`
113
236
  : `/dynamic/${model}/${recordId}`;
@@ -115,17 +238,18 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
115
238
  if (cancelled)
116
239
  return;
117
240
  const rec = recRes.data?.data ?? recRes.data;
118
- setRecord(rec);
119
- const initial = {};
120
- for (const field of meta?.fields ?? []) {
121
- initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? '';
122
- }
123
- setFormValues(initial);
241
+ // Merge so the fetched record fills gaps without dropping the
242
+ // table's pro siblings (the detail endpoint may omit them).
243
+ const merged = seed ? { ...seed, ...rec } : rec;
244
+ setRecord(merged);
245
+ if (meta)
246
+ seedForm(meta, merged);
124
247
  }
125
248
  }
126
249
  catch (err) {
127
250
  console.error('[DynamicRecordDialog] load error:', err);
128
- toast.error('Error al cargar los datos');
251
+ if (!seed)
252
+ toast.error('Error al cargar los datos');
129
253
  }
130
254
  finally {
131
255
  if (!cancelled)
@@ -134,14 +258,51 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
134
258
  };
135
259
  load();
136
260
  return () => { cancelled = true; };
137
- }, [open, recordId, model, endpoint, isCreate, schema, defaults]);
261
+ // initialRecord intentionally omitted: the row identity is captured per open
262
+ // via recordId; re-seeding mid-open would clobber edits.
263
+ // eslint-disable-next-line react-hooks/exhaustive-deps
264
+ }, [open, recordId, model, endpoint, isCreate, schema]);
265
+ // Reset when closed
138
266
  useEffect(() => {
139
267
  if (!open) {
140
268
  setModalMeta(null);
269
+ setRelations([]);
141
270
  setRecord(null);
142
271
  setFormValues({});
143
272
  }
144
273
  }, [open]);
274
+ // Fetch the model's declared one_to_many/many_to_many edges so view AND edit
275
+ // show child records (e.g. a sales order's line items) below the scalar
276
+ // fields. The modal form is driven by MODAL metadata (fields); relations live
277
+ // on TABLE metadata, hence the separate fetch. Skipped on create (no parent
278
+ // record yet). View renders them read-only; edit lets the user add/edit/delete.
279
+ useEffect(() => {
280
+ if (!open || mode === 'create' || !recordId) {
281
+ setRelations([]);
282
+ return;
283
+ }
284
+ let cancelled = false;
285
+ api.get(`/metadata/table/${model}`)
286
+ .then(res => {
287
+ if (cancelled)
288
+ return;
289
+ const meta = res.data?.data ?? res.data;
290
+ const rels = Array.isArray(meta?.relations) ? meta.relations : [];
291
+ // Localize each panel header: the backend serves `label` as an
292
+ // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
293
+ setRelations(rels.map(rel => ({
294
+ ...rel,
295
+ label: rel.label && t(rel.label) !== rel.label
296
+ ? t(rel.label)
297
+ : rel.label || rel.name,
298
+ })));
299
+ })
300
+ .catch(() => {
301
+ if (!cancelled)
302
+ setRelations([]);
303
+ });
304
+ return () => { cancelled = true; };
305
+ }, [open, mode, model, recordId, api, t]);
145
306
  const handleSubmit = async (e) => {
146
307
  e?.preventDefault();
147
308
  if (!modalMeta)
@@ -157,16 +318,16 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
157
318
  setSaving(true);
158
319
  try {
159
320
  if (isCreate && onCreate) {
160
- await onCreate(formValues);
161
- toast.success('Registro creado correctamente');
162
- onSaved?.();
321
+ const created = await onCreate(formValues);
322
+ toast.success(modalMeta?.messages?.created || 'Registro creado correctamente');
323
+ onSaved?.(created ?? undefined);
163
324
  onOpenChange(false);
164
325
  return;
165
326
  }
166
327
  if (!isCreate && recordId && onUpdate) {
167
- await onUpdate(String(recordId), formValues);
168
- toast.success('Guardado correctamente');
169
- onSaved?.();
328
+ const updated = await onUpdate(String(recordId), formValues);
329
+ toast.success(modalMeta?.messages?.updated || 'Guardado correctamente');
330
+ onSaved?.(updated ?? undefined);
170
331
  onOpenChange(false);
171
332
  return;
172
333
  }
@@ -182,8 +343,13 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
182
343
  res = await api.put(updateEndpoint, formValues);
183
344
  }
184
345
  if (res.data?.success !== false) {
185
- toast.success(res.data?.message || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'));
186
- onSaved?.();
346
+ // Prefer the addon's localized message (modal metadata), then a
347
+ // localized fallback. NOT res.data.message — the dynamic CRUD
348
+ // endpoint returns a raw English string that would leak into the toast.
349
+ toast.success(modalMeta?.messages?.[isCreate ? 'created' : 'updated']
350
+ || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'));
351
+ // Hand the persisted record back so callers can auto-select it.
352
+ onSaved?.(res.data?.data ?? res.data ?? undefined);
187
353
  onOpenChange(false);
188
354
  }
189
355
  else {
@@ -213,7 +379,7 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
213
379
  setDeleting(false);
214
380
  }
215
381
  };
216
- const title = modalMeta ? config.getTitle(modalMeta) : '';
382
+ const title = modalMeta ? config.getTitle(modalMeta, t) : '';
217
383
  const visibleFields = modalMeta?.fields?.filter(f => {
218
384
  if (f.hidden)
219
385
  return false;
@@ -221,10 +387,10 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
221
387
  return false;
222
388
  return true;
223
389
  }) ?? [];
224
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: [visibleFields.map(field => {
225
- const isFullWidth = field.type === 'textarea';
226
- return (_jsx("div", { className: isFullWidth ? 'sm:col-span-2' : '', children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode, onChange: val => setFormValues((prev) => ({ ...prev, [field.key]: val })) }) }, field.key));
227
- }), record?.external_url && (_jsx("div", { className: "sm:col-span-2", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }))] }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel }), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] }) }));
390
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsxs(TimeZoneContext.Provider, { value: timeZone, children: [_jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: [visibleFields.map(field => {
391
+ const isFullWidth = field.type === 'textarea';
392
+ return (_jsx("div", { className: isFullWidth ? 'sm:col-span-2' : '', children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode, onChange: val => setFormValues((prev) => ({ ...prev, [field.key]: val })) }) }, field.key));
393
+ }), record?.external_url && (_jsx("div", { className: "sm:col-span-2", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit' }) }))] }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel }), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
228
394
  }
229
395
  function LoadingSkeleton() {
230
396
  return (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: Array.from({ length: 6 }).map((_, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-24" }), _jsx(Skeleton, { className: "h-9 w-full" })] }, i))) }));
@@ -233,12 +399,75 @@ function FieldRow({ field, record, value, mode, onChange }) {
233
399
  const isReadonly = field.readonly || mode === 'view';
234
400
  return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), isReadonly ? (_jsx(ViewValue, { field: field, value: value, record: record })) : (_jsx(EditField, { field: field, value: value, onChange: onChange }))] }));
235
401
  }
236
- function ViewValue({ field, value: rawValue }) {
237
- // Normalize the nil UUID to undefined up front so the search/url/color/
238
- // image/select branches all fall through to their empty states.
402
+ // RelationViewValue read-only FK lead. Resolves the relation's label + image
403
+ // from (1) the sibling object the table served, then (2) the canonical options
404
+ // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
405
+ function RelationViewValue({ field, value, record }) {
406
+ const getImageUrl = useContext(ImageUrlContext);
407
+ const sib = relationSiblingValue(field, record);
408
+ const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib);
409
+ const sibImage = pickImage(sib);
410
+ // The raw FK id, tolerating an inline resolved object as the value itself.
411
+ const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value;
412
+ const inlineLabel = sibLabel ?? objectLabel(value);
413
+ const inlineImage = sibImage ?? pickImage(value);
414
+ const fieldRef = getFieldRef(field);
415
+ // Only resolve over the network when we still lack both label and image and
416
+ // there is something to look up.
417
+ const needResolve = !inlineLabel && !inlineImage && !!(fieldRef || field.searchEndpoint) && rawVal != null && rawVal !== '';
418
+ const { options } = useOptionsResolver({
419
+ modelKey: '',
420
+ fieldKey: 'id',
421
+ ref: fieldRef,
422
+ endpoint: fieldRef ? undefined : field.searchEndpoint,
423
+ query: '',
424
+ limit: 50,
425
+ enabled: needResolve,
426
+ });
427
+ const resolved = options.find(o => String(o.id) === String(rawVal));
428
+ const label = inlineLabel ??
429
+ resolved?.label ??
430
+ (rawVal != null && rawVal !== '' && !isNilUuid(rawVal) ? String(rawVal) : undefined);
431
+ const image = inlineImage ?? resolved?.image ?? undefined;
432
+ if (!label && !image) {
433
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
434
+ }
435
+ const lead = {
436
+ image: image ? getImageUrl(image) : null,
437
+ color: resolved?.color ?? null,
438
+ icon: resolved?.icon ?? null,
439
+ };
440
+ return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(OptionLead, { option: lead, size: 24 }), _jsx("span", { className: "text-sm", children: label ?? '—' })] }));
441
+ }
442
+ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, }) {
443
+ const ctxImageUrl = useContext(ImageUrlContext);
444
+ const ctxTimeZone = useContext(TimeZoneContext);
445
+ const getImageUrl = getImageUrlProp ?? ctxImageUrl;
446
+ const timeZone = timeZoneProp ?? ctxTimeZone;
447
+ // created_by / avatar resolver sibling → name (+ avatar) instead of "—".
448
+ if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
449
+ const user = createdBySibling(rawValue, record);
450
+ if (user) {
451
+ return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [user.avatar ? (_jsx("img", { src: getImageUrl(String(user.avatar)), alt: user.name ?? '', className: "h-6 w-6 rounded-full object-cover" })) : null, _jsx("span", { className: "text-sm", children: user.name ?? user.email ?? '—' })] }));
452
+ }
453
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
454
+ }
455
+ // Nil/zero UUID (unset nullable FK serialized as all-zeros) → empty marker.
456
+ if (isNilUuid(rawValue)) {
457
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
458
+ }
239
459
  const value = normalizeNilUuid(rawValue);
240
- if (field.type === 'search' && value) {
241
- return _jsx(SearchViewValue, { field: field, value: value });
460
+ // Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
461
+ // label. The *_id catch-all covers plain-typed FK columns not tagged as a
462
+ // relation field.
463
+ if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
464
+ return _jsx(RelationViewValue, { field: field, value: value, record: record });
465
+ }
466
+ // The value is itself a resolved object the backend served inline — render
467
+ // its label/name, never the raw JSON.
468
+ const inlineLabel = objectLabel(value);
469
+ if (inlineLabel !== undefined) {
470
+ return _jsx("p", { className: "text-sm py-1", children: inlineLabel });
242
471
  }
243
472
  if (field.type === 'boolean' || typeof value === 'boolean') {
244
473
  return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(Switch, { checked: !!value, disabled: true }), _jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' })] }));
@@ -247,16 +476,31 @@ function ViewValue({ field, value: rawValue }) {
247
476
  return value ? (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "h-5 w-5 rounded-full border shadow-sm", style: { backgroundColor: value } }), _jsx("span", { className: "text-sm", children: value })] })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "-" }));
248
477
  }
249
478
  if (field.type === 'image') {
250
- return value ? (_jsx("img", { src: value, alt: field.label, className: "h-16 w-16 rounded-lg object-cover border" })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "Sin imagen" }));
479
+ return value ? (_jsx("img", { src: getImageUrl(String(value)), alt: field.label, className: "h-16 w-16 rounded-lg object-cover border" })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "Sin imagen" }));
251
480
  }
252
481
  if (field.type === 'url' && value) {
253
482
  return (_jsx("a", { href: value, target: "_blank", rel: "noreferrer", className: "text-sm text-primary hover:underline truncate", children: value }));
254
483
  }
255
- if (field.type === 'select' && field.options?.length) {
256
- const match = field.options.find(o => o.value === String(value ?? ''));
257
- if (match) {
258
- return _jsx(Badge, { variant: "secondary", className: "w-fit", children: match.label });
484
+ // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
485
+ // day); instants render in the org timezone with a full-precision tooltip.
486
+ if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
487
+ const renderAs = field.type === 'date' ? 'date' : field.type;
488
+ const formatted = formatDateCell(value, renderAs, es, timeZone);
489
+ if (formatted) {
490
+ return (_jsx("p", { className: "text-sm py-1", title: formatted.title, children: formatted.display }));
259
491
  }
492
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
493
+ }
494
+ // Enum/option field with served options → colored/iconed badge using the
495
+ // served label (e.g. "Almacenable" instead of "storable").
496
+ const opt = servedOption(field, value);
497
+ if (opt) {
498
+ const lead = {
499
+ image: opt.image ? getImageUrl(opt.image) : null,
500
+ color: opt.color ?? null,
501
+ icon: opt.icon ?? null,
502
+ };
503
+ return (_jsxs(Badge, { variant: "secondary", className: "w-fit flex items-center gap-1", style: opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined, children: [_jsx(OptionLead, { option: lead, size: 16 }), opt.label] }));
260
504
  }
261
505
  const display = formatDisplayValue(value, field);
262
506
  if (field.type === 'textarea') {
@@ -272,17 +516,14 @@ function EditField({ field, value, onChange }) {
272
516
  return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4 }));
273
517
  }
274
518
  // Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
275
- // `image` type) for a file/photo column. Both render the themed dropzone
276
- // that POSTs to the host upload endpoint — same control as the Brand logo.
519
+ // `image` type) for a file/photo column.
277
520
  if (field.type === 'image' || field.widget === 'upload') {
278
521
  return _jsx(ImageUploadField, { field: field, value: value, onChange: onChange });
279
522
  }
280
523
  // FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
281
- // `widget: 'dynamic_select'` renders the async searchable picker against
282
- // /api/options/<ref>?field=id — with option thumbnails when the remote rows
283
- // carry an `image` instead of a raw FK uuid text input. Static inline
284
- // `options` are handled by the enum <Select> branch below; a ref column does
285
- // not ship inline options, so this never shadows a static enum.
524
+ // `widget: 'dynamic_select'` renders the SDK's async searchable picker — with
525
+ // option thumbnails and the inline-create "+" — against /api/options/<ref>.
526
+ // Static inline `options` are handled by the enum <Select> branch below.
286
527
  if ((getFieldRef(field) || field.widget === 'dynamic_select') && !field.options?.length) {
287
528
  return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
288
529
  }
@@ -315,6 +556,7 @@ function EditField({ field, value, onChange }) {
315
556
  function ImageUploadField({ field: _field, value, onChange }) {
316
557
  const api = useApi();
317
558
  const model = useContext(ModelContext);
559
+ const getImageUrl = useContext(ImageUrlContext);
318
560
  const [uploading, setUploading] = useState(false);
319
561
  const inputRef = useRef(null);
320
562
  async function handleFile(e) {
@@ -340,7 +582,7 @@ function ImageUploadField({ field: _field, value, onChange }) {
340
582
  inputRef.current.value = '';
341
583
  }
342
584
  }
343
- return (_jsxs("div", { className: "flex items-center gap-3", children: [value ? (_jsxs("div", { className: "relative", children: [_jsx("img", { src: value, alt: "", className: "h-16 w-16 rounded-lg object-cover border" }), _jsx("button", { type: "button", onClick: () => onChange(''), className: "absolute -top-1.5 -right-1.5 size-5 bg-destructive text-white rounded-full flex items-center justify-center hover:bg-destructive/90", children: _jsx(XIcon, { className: "size-3" }) })] })) : (_jsx("button", { type: "button", onClick: () => inputRef.current?.click(), disabled: uploading, className: "h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 hover:bg-muted/50 transition-colors disabled:opacity-50", children: uploading ? (_jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" })) : (_jsx(Upload, { className: "size-4 text-muted-foreground" })) })), _jsx("input", { ref: inputRef, type: "file", accept: "image/*", onChange: handleFile, className: "hidden" }), !value && _jsx("span", { className: "text-xs text-muted-foreground", children: "PNG, JPG, WebP" })] }));
585
+ return (_jsxs("div", { className: "flex items-center gap-3", children: [value ? (_jsxs("div", { className: "relative", children: [_jsx("img", { src: getImageUrl(String(value)), alt: "", className: "h-16 w-16 rounded-lg object-cover border" }), _jsx("button", { type: "button", onClick: () => onChange(''), className: "absolute -top-1.5 -right-1.5 size-5 bg-destructive text-white rounded-full flex items-center justify-center hover:bg-destructive/90", children: _jsx(XIcon, { className: "size-3" }) })] })) : (_jsx("button", { type: "button", onClick: () => inputRef.current?.click(), disabled: uploading, className: "h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 hover:bg-muted/50 transition-colors disabled:opacity-50", children: uploading ? (_jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" })) : (_jsx(Upload, { className: "size-4 text-muted-foreground" })) })), _jsx("input", { ref: inputRef, type: "file", accept: "image/*", onChange: handleFile, className: "hidden" }), !value && _jsx("span", { className: "text-xs text-muted-foreground", children: "PNG, JPG, WebP" })] }));
344
586
  }
345
587
  function extractArray(res) {
346
588
  const d = res.data;
@@ -351,31 +593,6 @@ function extractArray(res) {
351
593
  return [];
352
594
  }
353
595
  const searchCache = new Map();
354
- function SearchViewValue({ field, value }) {
355
- const api = useApi();
356
- const [label, setLabel] = useState(String(value));
357
- useEffect(() => {
358
- if (!field.searchEndpoint || !value)
359
- return;
360
- const cacheKey = field.searchEndpoint;
361
- const cached = searchCache.get(cacheKey);
362
- if (cached) {
363
- const match = cached.find((item) => item.value === value || item.id === value);
364
- if (match) {
365
- setLabel(match.label || match.name || String(value));
366
- return;
367
- }
368
- }
369
- api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
370
- const items = extractArray(res);
371
- searchCache.set(cacheKey, items);
372
- const match = items.find((item) => item.value === value || item.id === value);
373
- if (match)
374
- setLabel(match.label || match.name || String(value));
375
- }).catch(() => { });
376
- }, [value, field.searchEndpoint]);
377
- return _jsx("p", { className: "text-sm py-1", children: label });
378
- }
379
596
  function SearchField({ field, value, onChange }) {
380
597
  const api = useApi();
381
598
  const [open, setOpen] = useState(false);
@@ -401,7 +618,7 @@ function SearchField({ field, value, onChange }) {
401
618
  if (match)
402
619
  setSelectedLabel(match.label || match.name || '');
403
620
  }).catch(() => { });
404
- }, [value, field.searchEndpoint]);
621
+ }, [value, field.searchEndpoint, api]);
405
622
  useEffect(() => {
406
623
  if (!open || !field.searchEndpoint)
407
624
  return;
@@ -423,7 +640,7 @@ function SearchField({ field, value, onChange }) {
423
640
  .finally(() => setLoading(false));
424
641
  }, query ? 250 : 0);
425
642
  return () => clearTimeout(timer);
426
- }, [query, open, field.searchEndpoint]);
643
+ }, [query, open, field.searchEndpoint, api]);
427
644
  return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", className: cn("w-full justify-between font-normal h-9", !value && "text-muted-foreground"), children: [_jsx("span", { className: "truncate", children: selectedLabel || `Seleccionar ${field.label?.toLowerCase() || ''}...` }), _jsx(ChevronDown, { className: "ml-auto h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[--radix-popover-trigger-width] p-0", align: "start", side: "bottom", sideOffset: 4, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: `Buscar ${field.label?.toLowerCase() || ''}...`, value: query, onValueChange: setQuery }), _jsx(CommandList, { className: "max-h-[200px]", children: loading ? (_jsxs("div", { className: "py-6 text-center text-sm", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin mx-auto mb-1 text-muted-foreground" }), _jsx("span", { className: "text-muted-foreground text-xs", children: "Buscando..." })] })) : results.length === 0 ? (_jsx(CommandEmpty, { children: "Sin resultados." })) : (_jsx(CommandGroup, { children: results.map((item) => {
428
645
  const itemValue = item.value ?? item.id;
429
646
  const itemLabel = item.label ?? item.name ?? '';
@@ -433,6 +650,6 @@ function SearchField({ field, value, onChange }) {
433
650
  setSelectedLabel(itemLabel);
434
651
  setOpen(false);
435
652
  setQuery('');
436
- }, children: [isSelected && _jsx(Check, { className: "mr-2 h-3.5 w-3.5 shrink-0 text-primary" }), item.image && (_jsx("img", { src: item.image, className: "h-5 w-5 rounded mr-2 object-cover shrink-0", alt: "" })), _jsxs("div", { className: "flex flex-col min-w-0", children: [_jsx("span", { className: "truncate", children: itemLabel }), item.description && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate", children: item.description }))] })] }, itemValue));
653
+ }, children: [isSelected && _jsx(Check, { className: "mr-2 h-3.5 w-3.5 shrink-0 text-primary" }), item.image && (_jsx(OptionThumb, { image: item.image, size: 20 })), _jsxs("div", { className: "flex flex-col min-w-0 ml-2", children: [_jsx("span", { className: "truncate", children: itemLabel }), item.description && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate", children: item.description }))] })] }, itemValue));
437
654
  }) })) })] }) })] }));
438
655
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAO9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,+BAkGlB"}
1
+ {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAO9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,+BAmGlB"}