@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,8 +1,16 @@
1
1
  // DynamicRecordDialog — renders a create/edit/view modal for a model based
2
- // on metadata fetched from `/metadata/modal/:model`. Ported from the ops
3
- // starter. Host-owned infra that was referenced by alias (axios client,
4
- // branch store) now flows through <ApiProvider> from runtime-react.
2
+ // on metadata fetched from `/metadata/modal/:model`. This is the single,
3
+ // SDK-owned source of truth for declarative record rendering (the ops fork was
4
+ // consolidated back into here): tz-aware dates, FK image/label leads in both
5
+ // view and edit, resolved relation/user-object labels (never raw JSON), nil-UUID
6
+ // elision, pro option color/icon badges, and one_to_many child panels.
7
+ //
8
+ // Host-owned infra that was referenced by alias (axios client, branch store)
9
+ // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
10
+ // the image-url resolver and the org IANA timezone — are passed as props so the
11
+ // SDK stays transport- and host-agnostic.
5
12
  import { createContext, useContext, useEffect, useRef, useState } from 'react'
13
+ import { useTranslation } from 'react-i18next'
6
14
  import type { ModelSchema } from './types'
7
15
  import {
8
16
  Dialog,
@@ -40,18 +48,35 @@ import { format, parseISO } from 'date-fns'
40
48
  import { es } from 'date-fns/locale'
41
49
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
42
50
  import { useApi } from '../api-context'
43
- import { DynamicSelectField } from '../dynamic-select-field'
51
+ import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
52
+ import { DynamicRelations } from '../dynamic-relations'
53
+ import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
44
54
  import { getFieldRef } from '../dynamic-form-schema'
45
- import { normalizeNilUuid } from '../nil-uuid'
55
+ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
46
56
  import { humanizeToken } from '../dynamic-columns-helpers'
47
- import type { ActionFieldDef } from '../types'
57
+ import { formatDateCell } from '../dynamic-columns'
58
+ import type { ActionFieldDef, RelationMeta } from '../types'
59
+ import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
48
60
 
49
- interface FieldOption {
61
+ // Re-export the resolver type so `index.ts`'s
62
+ // `export type { … GetImageUrl } from './dialogs/dynamic-record'` keeps working.
63
+ export type { GetImageUrl }
64
+
65
+ export interface FieldOption {
50
66
  value: string
51
67
  label: string
68
+ /**
69
+ * Pro option metadata the backend serves for enum/option fields (e.g.
70
+ * `product_type`) so the view renders a colored/iconed badge instead of the
71
+ * raw value ("storable" → "Almacenable"). All optional and driven entirely
72
+ * by the served metadata — plain options stay plain.
73
+ */
74
+ color?: string
75
+ icon?: string
76
+ image?: string
52
77
  }
53
78
 
54
- interface FieldDef {
79
+ export interface FieldDef {
55
80
  key: string
56
81
  label: string
57
82
  type: 'text' | 'textarea' | 'select' | 'search' | 'number' | 'date' | 'email' | 'url' | 'boolean' | 'image' | string
@@ -68,8 +93,9 @@ interface FieldDef {
68
93
  * v0.46.x serves it on modal fields, not just action fields). When present
69
94
  * the native form renders an async searchable picker (`DynamicSelectField`)
70
95
  * against `/api/options/<ref>?field=id` — with option thumbnails when the
71
- * remote rows carry an `image` — instead of a raw FK text input. Tolerates
72
- * the snake_case `source`/`relation` aliases the manifest may serve.
96
+ * remote rows carry an `image` — instead of a raw FK text input. View mode
97
+ * shows the resolved thumbnail + label. Tolerates the snake_case
98
+ * `source`/`relation` aliases the manifest may serve.
73
99
  */
74
100
  ref?: string
75
101
  source?: string
@@ -88,9 +114,30 @@ interface FieldDef {
88
114
  // `ModelSchema` (see ./types.ts) is structurally assignable here.
89
115
  interface ModalMetadata {
90
116
  title?: string
117
+ /**
118
+ * i18n key for the model name (e.g. "accounting.model.account"). The backend
119
+ * can't always localize it (the addon bundle is only registered at install
120
+ * time), so it ships the key and we translate here — the frontend loads each
121
+ * addon's i18n live from the hub, so it resolves without a reinstall.
122
+ */
123
+ titleKey?: string
91
124
  createTitle?: string
92
125
  editTitle?: string
93
126
  fields?: FieldDef[]
127
+ /**
128
+ * Backend-localized CRUD success messages (modal metadata). Preferred over
129
+ * the raw response message which is not localized.
130
+ */
131
+ messages?: { created?: string; updated?: string; deleted?: string }
132
+ }
133
+
134
+ type TFn = (key: string) => string
135
+
136
+ // localizedModelName resolves the (possibly addon-i18n) model name: prefer the
137
+ // translated titleKey, fall back to the backend-provided raw title.
138
+ function localizedModelName(meta: ModalMetadata, t: TFn): string {
139
+ if (meta.titleKey && t(meta.titleKey) !== meta.titleKey) return t(meta.titleKey)
140
+ return meta.title || ''
94
141
  }
95
142
 
96
143
  export interface DynamicRecordDialogProps {
@@ -100,7 +147,10 @@ export interface DynamicRecordDialogProps {
100
147
  model: string
101
148
  recordId?: string | null
102
149
  endpoint?: string
103
- onSaved?: () => void
150
+ /** Fired after a successful save; receives the persisted record (when the
151
+ * backend returns it) so callers — e.g. the inline-create bridge behind a
152
+ * dynamic_select "+" — can auto-select the new row. */
153
+ onSaved?: (record?: any) => void
104
154
  /**
105
155
  * Optional override invoked instead of the default `POST` when the dialog
106
156
  * is in `create` mode. Hosts may use this to route writes through custom
@@ -133,28 +183,111 @@ export interface DynamicRecordDialogProps {
133
183
  * the action.
134
184
  */
135
185
  onEdit?: () => void
186
+ /**
187
+ * Deliberate escape hatch: open the full `/m/:model/:id` detail page (with
188
+ * cross-module related records) for records too heavy for the modal.
189
+ * Rendered as a footer link in view mode when provided.
190
+ */
191
+ onOpenFullPage?: () => void
192
+ /**
193
+ * The row object the table already loaded. When provided, the dialog renders
194
+ * instantly from it (no spinner) and reuses the table's pro siblings — the
195
+ * resolved relation (`row.category = {value,label}`), served option lists and
196
+ * image urls. A background fetch only fills in fields the list row omitted.
197
+ */
198
+ initialRecord?: Record<string, any> | null
199
+ /**
200
+ * Host resolver turning a (possibly relative) storage path into a fetchable
201
+ * URL for images/avatars/thumbnails. Defaults to identity. Pass the host's
202
+ * `getImageUrl` so addon-served relative paths render.
203
+ */
204
+ getImageUrl?: GetImageUrl
205
+ /**
206
+ * Org IANA timezone (e.g. `America/Mexico_City`). Threaded into the tz-aware
207
+ * `formatDateCell` so datetime/timestamp instants render in the org zone
208
+ * regardless of the viewer's browser timezone. Pure `date` values pin to UTC.
209
+ */
210
+ timeZone?: string
136
211
  }
137
212
 
138
213
  function resolvePath(obj: any, path: string): any {
139
214
  return path.split('.').reduce((acc, part) => acc?.[part], obj)
140
215
  }
141
216
 
217
+ // objectLabel pulls a human label off a resolved relation/user object the
218
+ // backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
219
+ // as created_by), or `{title}`. Returns undefined for plain/empty objects.
220
+ function objectLabel(value: any): string | undefined {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
222
+ const label = value.label ?? value.name ?? value.title
223
+ if (label != null && label !== '') return String(label)
224
+ return undefined
225
+ }
226
+
227
+ // pickImage reads an image-ish path off a resolved object (FK sibling, user).
228
+ function pickImage(value: any): string | undefined {
229
+ if (!value || typeof value !== 'object') return undefined
230
+ const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail
231
+ return typeof img === 'string' && img !== '' ? img : undefined
232
+ }
233
+
234
+ // relationSiblingValue reads the resolved relation the table served alongside an
235
+ // FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
236
+ // `record.category = {value,label,image?}` (or a bare string/{name}); returns
237
+ // the raw sibling (object or string) so the caller can extract label + image.
238
+ function relationSiblingValue(field: FieldDef, record: any): any {
239
+ if (!record) return undefined
240
+ const candidates: string[] = []
241
+ const ref = getFieldRef(field as ActionFieldDef)
242
+ if (ref) candidates.push(ref)
243
+ if (typeof field.key === 'string' && field.key.endsWith('_id')) candidates.push(field.key.slice(0, -3))
244
+ for (const key of candidates) {
245
+ const sib = record[key]
246
+ if (sib === undefined || sib === null) continue
247
+ if (typeof sib === 'string') {
248
+ if (sib === '' || isNilUuid(sib)) continue
249
+ return sib
250
+ }
251
+ if (typeof sib === 'object') return sib
252
+ }
253
+ return undefined
254
+ }
255
+
256
+ // servedOption matches a field's served option list (enum/select with
257
+ // {value,label,color,icon,image}) against the current value.
258
+ function servedOption(field: FieldDef, value: any): FieldOption | undefined {
259
+ if (!field.options?.length) return undefined
260
+ return field.options.find(o => o.value === String(value ?? ''))
261
+ }
262
+
263
+ // createdBySibling reads the resolver object the backend serves for the
264
+ // auto-injected `created_by` avatar column: {name, avatar, email}.
265
+ function createdBySibling(value: any, record: any): { name?: string; avatar?: string; email?: string } | undefined {
266
+ const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by
267
+ if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email)) return obj
268
+ return undefined
269
+ }
270
+
271
+ // isRelationField — a field that resolves to another row (so view renders a lead
272
+ // + label and edit renders the searchable picker).
273
+ function isRelationField(field: FieldDef): boolean {
274
+ return (
275
+ field.type === 'search' ||
276
+ field.type === 'dynamic_select' ||
277
+ field.widget === 'dynamic_select' ||
278
+ !!getFieldRef(field as ActionFieldDef) ||
279
+ !!field.searchEndpoint
280
+ )
281
+ }
282
+
142
283
  function formatDisplayValue(rawValue: any, field: FieldDef): string {
143
284
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
144
285
  const value = normalizeNilUuid(rawValue)
145
286
  if (value === null || value === undefined || value === '') return '—'
287
+ const objLabel = objectLabel(value)
288
+ if (objLabel !== undefined) return objLabel
146
289
  if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No'
147
290
 
148
- if (field.type === 'date') {
149
- try {
150
- return new Date(value).toLocaleDateString('es-MX', {
151
- day: 'numeric', month: 'long', year: 'numeric',
152
- })
153
- } catch {
154
- return String(value)
155
- }
156
- }
157
-
158
291
  if (field.type === 'select' && field.options?.length) {
159
292
  const match = field.options.find(o => o.value === String(value))
160
293
  // Matched option label wins (localized); humanize the raw token only
@@ -167,21 +300,27 @@ function formatDisplayValue(rawValue: any, field: FieldDef): string {
167
300
 
168
301
  const MODE_CONFIG = {
169
302
  create: {
170
- getTitle: (meta: ModalMetadata) => meta.createTitle || meta.title || 'Nuevo registro',
303
+ getTitle: (meta: ModalMetadata, t: TFn) => {
304
+ const name = localizedModelName(meta, t)
305
+ return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro')
306
+ },
171
307
  description: 'Completa los campos para crear un nuevo registro.',
172
308
  submitLabel: 'Crear',
173
309
  submittingLabel: 'Creando...',
174
310
  cancelLabel: 'Cancelar',
175
311
  },
176
312
  edit: {
177
- getTitle: (meta: ModalMetadata) => meta.editTitle || meta.title || 'Editar registro',
313
+ getTitle: (meta: ModalMetadata, t: TFn) => {
314
+ const name = localizedModelName(meta, t)
315
+ return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro')
316
+ },
178
317
  description: 'Modifica los campos y guarda los cambios.',
179
318
  submitLabel: 'Guardar cambios',
180
319
  submittingLabel: 'Guardando...',
181
320
  cancelLabel: 'Cancelar',
182
321
  },
183
322
  view: {
184
- getTitle: (meta: ModalMetadata) => meta.title || 'Ver registro',
323
+ getTitle: (meta: ModalMetadata, t: TFn) => localizedModelName(meta, t) || meta.title || 'Ver registro',
185
324
  description: 'Información detallada del registro.',
186
325
  submitLabel: '',
187
326
  submittingLabel: '',
@@ -189,7 +328,10 @@ const MODE_CONFIG = {
189
328
  },
190
329
  }
191
330
 
331
+ // Context threading host runtime values to nested field components (uploads,
332
+ // image leads, tz-aware dates) without prop-drilling through every renderer.
192
333
  const ModelContext = createContext('')
334
+ const TimeZoneContext = createContext<string | undefined>(undefined)
193
335
 
194
336
  export function DynamicRecordDialog({
195
337
  open,
@@ -205,11 +347,17 @@ export function DynamicRecordDialog({
205
347
  schema,
206
348
  onDelete,
207
349
  onEdit,
350
+ onOpenFullPage,
351
+ initialRecord,
352
+ getImageUrl = identityImageUrl,
353
+ timeZone,
208
354
  }: DynamicRecordDialogProps) {
209
355
  const api = useApi()
356
+ const { t } = useTranslation()
210
357
  const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
211
358
  schema ? (schema as ModalMetadata) : null,
212
359
  )
360
+ const [relations, setRelations] = useState<RelationMeta[]>([])
213
361
  const [record, setRecord] = useState<any | null>(null)
214
362
  const [formValues, setFormValues] = useState<Record<string, any>>({})
215
363
  const [loading, setLoading] = useState(false)
@@ -221,14 +369,40 @@ export function DynamicRecordDialog({
221
369
  const isEditable = mode === 'create' || mode === 'edit'
222
370
  const config = MODE_CONFIG[mode]
223
371
 
372
+ // ── Fetch metadata + record when dialog opens ──────────────────────────
224
373
  useEffect(() => {
225
374
  if (!open) return
226
375
  if (!isCreate && !recordId) return
227
376
 
228
377
  let cancelled = false
229
378
 
379
+ // Seed instantly from the row the table already has so view/edit render
380
+ // without a spinner. The list row carries the pro siblings (resolved
381
+ // relation, served options, image url) the table cells used.
382
+ const seed = !isCreate && initialRecord ? initialRecord : null
383
+ if (seed) setRecord(seed)
384
+
385
+ const seedForm = (meta: ModalMetadata, rec: any) => {
386
+ const initial: Record<string, any> = {}
387
+ for (const field of meta.fields ?? []) {
388
+ initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? ''
389
+ }
390
+ setFormValues(initial)
391
+ }
392
+
393
+ // A field value is "missing" from the seed row when the list omitted that
394
+ // column. Sibling pro fields aren't form fields, so we only check the
395
+ // declared field keys.
396
+ const seedIsComplete = (meta: ModalMetadata, rec: any) =>
397
+ (meta.fields ?? []).every(f => {
398
+ if (f.hidden) return true
399
+ const v = resolvePath(rec, f.key)
400
+ return v !== undefined
401
+ })
402
+
230
403
  const load = async () => {
231
- setLoading(true)
404
+ // Only show the skeleton when we have nothing to render yet.
405
+ if (!seed) setLoading(true)
232
406
  try {
233
407
  let meta: ModalMetadata | null = schema ? (schema as ModalMetadata) : null
234
408
  if (!meta) {
@@ -247,7 +421,15 @@ export function DynamicRecordDialog({
247
421
  : field.defaultValue) ?? ''
248
422
  }
249
423
  setFormValues(initial)
250
- } else {
424
+ return
425
+ }
426
+
427
+ // Render immediately from the seed row.
428
+ if (seed && meta) seedForm(meta, seed)
429
+
430
+ // Only hit the record endpoint if the seed is absent or missing
431
+ // some declared field — keeps the modal instant for full rows.
432
+ if (!seed || (meta && !seedIsComplete(meta, seed))) {
251
433
  const recordEndpoint = endpoint
252
434
  ? `${endpoint}/${recordId}`
253
435
  : `/dynamic/${model}/${recordId}`
@@ -256,17 +438,15 @@ export function DynamicRecordDialog({
256
438
  if (cancelled) return
257
439
 
258
440
  const rec = recRes.data?.data ?? recRes.data
259
- setRecord(rec)
260
-
261
- const initial: Record<string, any> = {}
262
- for (const field of meta?.fields ?? []) {
263
- initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? ''
264
- }
265
- setFormValues(initial)
441
+ // Merge so the fetched record fills gaps without dropping the
442
+ // table's pro siblings (the detail endpoint may omit them).
443
+ const merged = seed ? { ...seed, ...rec } : rec
444
+ setRecord(merged)
445
+ if (meta) seedForm(meta, merged)
266
446
  }
267
447
  } catch (err) {
268
448
  console.error('[DynamicRecordDialog] load error:', err)
269
- toast.error('Error al cargar los datos')
449
+ if (!seed) toast.error('Error al cargar los datos')
270
450
  } finally {
271
451
  if (!cancelled) setLoading(false)
272
452
  }
@@ -274,16 +454,55 @@ export function DynamicRecordDialog({
274
454
 
275
455
  load()
276
456
  return () => { cancelled = true }
277
- }, [open, recordId, model, endpoint, isCreate, schema, defaults])
457
+ // initialRecord intentionally omitted: the row identity is captured per open
458
+ // via recordId; re-seeding mid-open would clobber edits.
459
+ // eslint-disable-next-line react-hooks/exhaustive-deps
460
+ }, [open, recordId, model, endpoint, isCreate, schema])
278
461
 
462
+ // Reset when closed
279
463
  useEffect(() => {
280
464
  if (!open) {
281
465
  setModalMeta(null)
466
+ setRelations([])
282
467
  setRecord(null)
283
468
  setFormValues({})
284
469
  }
285
470
  }, [open])
286
471
 
472
+ // Fetch the model's declared one_to_many/many_to_many edges so view AND edit
473
+ // show child records (e.g. a sales order's line items) below the scalar
474
+ // fields. The modal form is driven by MODAL metadata (fields); relations live
475
+ // on TABLE metadata, hence the separate fetch. Skipped on create (no parent
476
+ // record yet). View renders them read-only; edit lets the user add/edit/delete.
477
+ useEffect(() => {
478
+ if (!open || mode === 'create' || !recordId) {
479
+ setRelations([])
480
+ return
481
+ }
482
+ let cancelled = false
483
+ api.get(`/metadata/table/${model}`)
484
+ .then(res => {
485
+ if (cancelled) return
486
+ const meta = res.data?.data ?? res.data
487
+ const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
488
+ // Localize each panel header: the backend serves `label` as an
489
+ // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
490
+ setRelations(
491
+ rels.map(rel => ({
492
+ ...rel,
493
+ label:
494
+ rel.label && t(rel.label) !== rel.label
495
+ ? t(rel.label)
496
+ : rel.label || rel.name,
497
+ })),
498
+ )
499
+ })
500
+ .catch(() => {
501
+ if (!cancelled) setRelations([])
502
+ })
503
+ return () => { cancelled = true }
504
+ }, [open, mode, model, recordId, api, t])
505
+
287
506
  const handleSubmit = async (e?: React.FormEvent) => {
288
507
  e?.preventDefault()
289
508
  if (!modalMeta) return
@@ -300,17 +519,17 @@ export function DynamicRecordDialog({
300
519
  setSaving(true)
301
520
  try {
302
521
  if (isCreate && onCreate) {
303
- await onCreate(formValues)
304
- toast.success('Registro creado correctamente')
305
- onSaved?.()
522
+ const created = await onCreate(formValues)
523
+ toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
524
+ onSaved?.(created ?? undefined)
306
525
  onOpenChange(false)
307
526
  return
308
527
  }
309
528
 
310
529
  if (!isCreate && recordId && onUpdate) {
311
- await onUpdate(String(recordId), formValues)
312
- toast.success('Guardado correctamente')
313
- onSaved?.()
530
+ const updated = await onUpdate(String(recordId), formValues)
531
+ toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
532
+ onSaved?.(updated ?? undefined)
314
533
  onOpenChange(false)
315
534
  return
316
535
  }
@@ -327,8 +546,15 @@ export function DynamicRecordDialog({
327
546
  }
328
547
 
329
548
  if (res.data?.success !== false) {
330
- toast.success(res.data?.message || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'))
331
- onSaved?.()
549
+ // Prefer the addon's localized message (modal metadata), then a
550
+ // localized fallback. NOT res.data.message — the dynamic CRUD
551
+ // endpoint returns a raw English string that would leak into the toast.
552
+ toast.success(
553
+ modalMeta?.messages?.[isCreate ? 'created' : 'updated']
554
+ || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'),
555
+ )
556
+ // Hand the persisted record back so callers can auto-select it.
557
+ onSaved?.(res.data?.data ?? res.data ?? undefined)
332
558
  onOpenChange(false)
333
559
  } else {
334
560
  toast.error(res.data?.message || 'Error al guardar')
@@ -354,7 +580,7 @@ export function DynamicRecordDialog({
354
580
  }
355
581
  }
356
582
 
357
- const title = modalMeta ? config.getTitle(modalMeta) : ''
583
+ const title = modalMeta ? config.getTitle(modalMeta, t) : ''
358
584
 
359
585
  const visibleFields = modalMeta?.fields?.filter(f => {
360
586
  if (f.hidden) return false
@@ -375,6 +601,8 @@ export function DynamicRecordDialog({
375
601
  <LoadingSkeleton />
376
602
  ) : modalMeta ? (
377
603
  <ModelContext.Provider value={model}>
604
+ <ImageUrlContext.Provider value={getImageUrl}>
605
+ <TimeZoneContext.Provider value={timeZone}>
378
606
  <form
379
607
  id="dynamic-record-form"
380
608
  onSubmit={handleSubmit}
@@ -414,39 +642,68 @@ export function DynamicRecordDialog({
414
642
  </div>
415
643
  )}
416
644
  </form>
645
+
646
+ {/* Child records (line items, etc.) for declared relations.
647
+ View = strictly read-only; edit = add/edit/delete. */}
648
+ {!isCreate && record && relations.length > 0 && (
649
+ <div className="mt-6">
650
+ <DynamicRelations
651
+ record={record}
652
+ relations={relations}
653
+ canCreate={mode === 'edit'}
654
+ canEdit={mode === 'edit'}
655
+ canDelete={mode === 'edit'}
656
+ />
657
+ </div>
658
+ )}
659
+ </TimeZoneContext.Provider>
660
+ </ImageUrlContext.Provider>
417
661
  </ModelContext.Provider>
418
662
  ) : null}
419
663
  </div>
420
664
 
421
- <DialogFooter className="p-4 border-t shrink-0">
422
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
423
- {config.cancelLabel}
424
- </Button>
425
- {isView && onDelete && (
665
+ <DialogFooter className="p-4 border-t shrink-0 sm:justify-between">
666
+ {isView && onOpenFullPage ? (
426
667
  <Button
427
- variant="destructive"
428
- onClick={handleDelete}
429
- disabled={deleting || loading}
668
+ variant="ghost"
669
+ size="sm"
670
+ className="text-muted-foreground"
671
+ onClick={() => { onOpenChange(false); onOpenFullPage() }}
430
672
  >
431
- {deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
432
- {deleting ? 'Eliminando...' : 'Eliminar'}
673
+ <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
674
+ Ver página completa
433
675
  </Button>
434
- )}
435
- {isView && onEdit && (
436
- <Button onClick={onEdit} disabled={deleting || loading}>
437
- Editar
676
+ ) : <span />}
677
+ <div className="flex items-center gap-2">
678
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
679
+ {config.cancelLabel}
438
680
  </Button>
439
- )}
440
- {isEditable && (
441
- <Button
442
- type="submit"
443
- form="dynamic-record-form"
444
- disabled={saving || loading}
445
- >
446
- {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
447
- {saving ? config.submittingLabel : config.submitLabel}
448
- </Button>
449
- )}
681
+ {isView && onDelete && (
682
+ <Button
683
+ variant="destructive"
684
+ onClick={handleDelete}
685
+ disabled={deleting || loading}
686
+ >
687
+ {deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
688
+ {deleting ? 'Eliminando...' : 'Eliminar'}
689
+ </Button>
690
+ )}
691
+ {isView && onEdit && (
692
+ <Button onClick={onEdit} disabled={deleting || loading}>
693
+ Editar
694
+ </Button>
695
+ )}
696
+ {isEditable && (
697
+ <Button
698
+ type="submit"
699
+ form="dynamic-record-form"
700
+ disabled={saving || loading}
701
+ >
702
+ {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
703
+ {saving ? config.submittingLabel : config.submitLabel}
704
+ </Button>
705
+ )}
706
+ </div>
450
707
  </DialogFooter>
451
708
  </DialogContent>
452
709
  </Dialog>
@@ -495,12 +752,113 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
495
752
  )
496
753
  }
497
754
 
498
- function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; record: any }) {
499
- // Normalize the nil UUID to undefined up front so the search/url/color/
500
- // image/select branches all fall through to their empty states.
755
+ // RelationViewValue read-only FK lead. Resolves the relation's label + image
756
+ // from (1) the sibling object the table served, then (2) the canonical options
757
+ // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
758
+ function RelationViewValue({ field, value, record }: { field: FieldDef; value: any; record: any }) {
759
+ const getImageUrl = useContext(ImageUrlContext)
760
+ const sib = relationSiblingValue(field, record)
761
+ const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
762
+ const sibImage = pickImage(sib)
763
+ // The raw FK id, tolerating an inline resolved object as the value itself.
764
+ const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value
765
+ const inlineLabel = sibLabel ?? objectLabel(value)
766
+ const inlineImage = sibImage ?? pickImage(value)
767
+
768
+ const fieldRef = getFieldRef(field as ActionFieldDef)
769
+ // Only resolve over the network when we still lack both label and image and
770
+ // there is something to look up.
771
+ const needResolve = !inlineLabel && !inlineImage && !!(fieldRef || field.searchEndpoint) && rawVal != null && rawVal !== ''
772
+ const { options } = useOptionsResolver({
773
+ modelKey: '',
774
+ fieldKey: 'id',
775
+ ref: fieldRef,
776
+ endpoint: fieldRef ? undefined : field.searchEndpoint,
777
+ query: '',
778
+ limit: 50,
779
+ enabled: needResolve,
780
+ })
781
+ const resolved = options.find(o => String(o.id) === String(rawVal))
782
+
783
+ const label =
784
+ inlineLabel ??
785
+ resolved?.label ??
786
+ (rawVal != null && rawVal !== '' && !isNilUuid(rawVal) ? String(rawVal) : undefined)
787
+ const image = inlineImage ?? resolved?.image ?? undefined
788
+
789
+ if (!label && !image) {
790
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
791
+ }
792
+
793
+ const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
794
+ image: image ? getImageUrl(image) : null,
795
+ color: resolved?.color ?? null,
796
+ icon: resolved?.icon ?? null,
797
+ }
798
+
799
+ return (
800
+ <div className="flex items-center gap-2 py-1">
801
+ <OptionLead option={lead} size={24} />
802
+ <span className="text-sm">{label ?? '—'}</span>
803
+ </div>
804
+ )
805
+ }
806
+
807
+ export function ViewValue({
808
+ field,
809
+ value: rawValue,
810
+ record,
811
+ getImageUrl: getImageUrlProp,
812
+ timeZone: timeZoneProp,
813
+ }: {
814
+ field: FieldDef
815
+ value: any
816
+ record: any
817
+ /** Optional override; when omitted falls back to the nearest provider/identity. */
818
+ getImageUrl?: GetImageUrl
819
+ /** Optional override; when omitted falls back to the nearest provider. */
820
+ timeZone?: string
821
+ }) {
822
+ const ctxImageUrl = useContext(ImageUrlContext)
823
+ const ctxTimeZone = useContext(TimeZoneContext)
824
+ const getImageUrl = getImageUrlProp ?? ctxImageUrl
825
+ const timeZone = timeZoneProp ?? ctxTimeZone
826
+
827
+ // created_by / avatar resolver sibling → name (+ avatar) instead of "—".
828
+ if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
829
+ const user = createdBySibling(rawValue, record)
830
+ if (user) {
831
+ return (
832
+ <div className="flex items-center gap-2 py-1">
833
+ {user.avatar ? (
834
+ <img src={getImageUrl(String(user.avatar))} alt={user.name ?? ''} className="h-6 w-6 rounded-full object-cover" />
835
+ ) : null}
836
+ <span className="text-sm">{user.name ?? user.email ?? '—'}</span>
837
+ </div>
838
+ )
839
+ }
840
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
841
+ }
842
+
843
+ // Nil/zero UUID (unset nullable FK serialized as all-zeros) → empty marker.
844
+ if (isNilUuid(rawValue)) {
845
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
846
+ }
847
+
501
848
  const value = normalizeNilUuid(rawValue)
502
- if (field.type === 'search' && value) {
503
- return <SearchViewValue field={field} value={value} />
849
+
850
+ // Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
851
+ // label. The *_id catch-all covers plain-typed FK columns not tagged as a
852
+ // relation field.
853
+ if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
854
+ return <RelationViewValue field={field} value={value} record={record} />
855
+ }
856
+
857
+ // The value is itself a resolved object the backend served inline — render
858
+ // its label/name, never the raw JSON.
859
+ const inlineLabel = objectLabel(value)
860
+ if (inlineLabel !== undefined) {
861
+ return <p className="text-sm py-1">{inlineLabel}</p>
504
862
  }
505
863
 
506
864
  if (field.type === 'boolean' || typeof value === 'boolean') {
@@ -527,7 +885,7 @@ function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; re
527
885
 
528
886
  if (field.type === 'image') {
529
887
  return value ? (
530
- <img src={value} alt={field.label} className="h-16 w-16 rounded-lg object-cover border" />
888
+ <img src={getImageUrl(String(value))} alt={field.label} className="h-16 w-16 rounded-lg object-cover border" />
531
889
  ) : (
532
890
  <p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
533
891
  )
@@ -546,11 +904,40 @@ function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; re
546
904
  )
547
905
  }
548
906
 
549
- if (field.type === 'select' && field.options?.length) {
550
- const match = field.options.find(o => o.value === String(value ?? ''))
551
- if (match) {
552
- return <Badge variant="secondary" className="w-fit">{match.label}</Badge>
907
+ // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
908
+ // day); instants render in the org timezone with a full-precision tooltip.
909
+ if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
910
+ const renderAs = field.type === 'date' ? 'date' : field.type
911
+ const formatted = formatDateCell(value, renderAs, es, timeZone)
912
+ if (formatted) {
913
+ return (
914
+ <p className="text-sm py-1" title={formatted.title}>
915
+ {formatted.display}
916
+ </p>
917
+ )
553
918
  }
919
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
920
+ }
921
+
922
+ // Enum/option field with served options → colored/iconed badge using the
923
+ // served label (e.g. "Almacenable" instead of "storable").
924
+ const opt = servedOption(field, value)
925
+ if (opt) {
926
+ const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
927
+ image: opt.image ? getImageUrl(opt.image) : null,
928
+ color: opt.color ?? null,
929
+ icon: opt.icon ?? null,
930
+ }
931
+ return (
932
+ <Badge
933
+ variant="secondary"
934
+ className="w-fit flex items-center gap-1"
935
+ style={opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined}
936
+ >
937
+ <OptionLead option={lead} size={16} />
938
+ {opt.label}
939
+ </Badge>
940
+ )
554
941
  }
555
942
 
556
943
  const display = formatDisplayValue(value, field)
@@ -597,18 +984,15 @@ function EditField({ field, value, onChange }: {
597
984
  }
598
985
 
599
986
  // Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
600
- // `image` type) for a file/photo column. Both render the themed dropzone
601
- // that POSTs to the host upload endpoint — same control as the Brand logo.
987
+ // `image` type) for a file/photo column.
602
988
  if (field.type === 'image' || field.widget === 'upload') {
603
989
  return <ImageUploadField field={field} value={value} onChange={onChange} />
604
990
  }
605
991
 
606
992
  // FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
607
- // `widget: 'dynamic_select'` renders the async searchable picker against
608
- // /api/options/<ref>?field=id — with option thumbnails when the remote rows
609
- // carry an `image` instead of a raw FK uuid text input. Static inline
610
- // `options` are handled by the enum <Select> branch below; a ref column does
611
- // not ship inline options, so this never shadows a static enum.
993
+ // `widget: 'dynamic_select'` renders the SDK's async searchable picker — with
994
+ // option thumbnails and the inline-create "+" — against /api/options/<ref>.
995
+ // Static inline `options` are handled by the enum <Select> branch below.
612
996
  if ((getFieldRef(field as ActionFieldDef) || field.widget === 'dynamic_select') && !field.options?.length) {
613
997
  return <DynamicSelectField field={field as ActionFieldDef} value={value} onChange={onChange} />
614
998
  }
@@ -710,6 +1094,7 @@ function EditField({ field, value, onChange }: {
710
1094
  function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
711
1095
  const api = useApi()
712
1096
  const model = useContext(ModelContext)
1097
+ const getImageUrl = useContext(ImageUrlContext)
713
1098
  const [uploading, setUploading] = useState(false)
714
1099
  const inputRef = useRef<HTMLInputElement>(null)
715
1100
 
@@ -737,7 +1122,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
737
1122
  <div className="flex items-center gap-3">
738
1123
  {value ? (
739
1124
  <div className="relative">
740
- <img src={value} alt="" className="h-16 w-16 rounded-lg object-cover border" />
1125
+ <img src={getImageUrl(String(value))} alt="" className="h-16 w-16 rounded-lg object-cover border" />
741
1126
  <button
742
1127
  type="button"
743
1128
  onClick={() => onChange('')}
@@ -775,29 +1160,6 @@ function extractArray(res: any): any[] {
775
1160
 
776
1161
  const searchCache = new Map<string, any[]>()
777
1162
 
778
- function SearchViewValue({ field, value }: { field: FieldDef; value: any }) {
779
- const api = useApi()
780
- const [label, setLabel] = useState(String(value))
781
-
782
- useEffect(() => {
783
- if (!field.searchEndpoint || !value) return
784
- const cacheKey = field.searchEndpoint
785
- const cached = searchCache.get(cacheKey)
786
- if (cached) {
787
- const match = cached.find((item: any) => item.value === value || item.id === value)
788
- if (match) { setLabel(match.label || match.name || String(value)); return }
789
- }
790
- api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
791
- const items = extractArray(res)
792
- searchCache.set(cacheKey, items)
793
- const match = items.find((item: any) => item.value === value || item.id === value)
794
- if (match) setLabel(match.label || match.name || String(value))
795
- }).catch(() => {})
796
- }, [value, field.searchEndpoint])
797
-
798
- return <p className="text-sm py-1">{label}</p>
799
- }
800
-
801
1163
  function SearchField({ field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
802
1164
  const api = useApi()
803
1165
  const [open, setOpen] = useState(false)
@@ -819,7 +1181,7 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
819
1181
  const match = items.find((item: any) => item.value === value || item.id === value)
820
1182
  if (match) setSelectedLabel(match.label || match.name || '')
821
1183
  }).catch(() => {})
822
- }, [value, field.searchEndpoint])
1184
+ }, [value, field.searchEndpoint, api])
823
1185
 
824
1186
  useEffect(() => {
825
1187
  if (!open || !field.searchEndpoint) return
@@ -837,7 +1199,7 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
837
1199
  .finally(() => setLoading(false))
838
1200
  }, query ? 250 : 0)
839
1201
  return () => clearTimeout(timer)
840
- }, [query, open, field.searchEndpoint])
1202
+ }, [query, open, field.searchEndpoint, api])
841
1203
 
842
1204
  return (
843
1205
  <Popover open={open} onOpenChange={setOpen}>
@@ -888,9 +1250,9 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
888
1250
  >
889
1251
  {isSelected && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-primary" />}
890
1252
  {item.image && (
891
- <img src={item.image} className="h-5 w-5 rounded mr-2 object-cover shrink-0" alt="" />
1253
+ <OptionThumb image={item.image} size={20} />
892
1254
  )}
893
- <div className="flex flex-col min-w-0">
1255
+ <div className="flex flex-col min-w-0 ml-2">
894
1256
  <span className="truncate">{itemLabel}</span>
895
1257
  {item.description && (
896
1258
  <span className="text-[11px] text-muted-foreground truncate">{item.description}</span>