@asteby/metacore-runtime-react 18.1.0 → 18.2.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,34 @@ 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'
48
59
 
49
- interface FieldOption {
60
+ /** Resolves a (possibly relative) storage path into a fetchable URL. */
61
+ export type GetImageUrl = (path: string | null | undefined) => string
62
+ const identityImageUrl: GetImageUrl = (p) => p ?? ''
63
+
64
+ export interface FieldOption {
50
65
  value: string
51
66
  label: string
67
+ /**
68
+ * Pro option metadata the backend serves for enum/option fields (e.g.
69
+ * `product_type`) so the view renders a colored/iconed badge instead of the
70
+ * raw value ("storable" → "Almacenable"). All optional and driven entirely
71
+ * by the served metadata — plain options stay plain.
72
+ */
73
+ color?: string
74
+ icon?: string
75
+ image?: string
52
76
  }
53
77
 
54
- interface FieldDef {
78
+ export interface FieldDef {
55
79
  key: string
56
80
  label: string
57
81
  type: 'text' | 'textarea' | 'select' | 'search' | 'number' | 'date' | 'email' | 'url' | 'boolean' | 'image' | string
@@ -68,8 +92,9 @@ interface FieldDef {
68
92
  * v0.46.x serves it on modal fields, not just action fields). When present
69
93
  * the native form renders an async searchable picker (`DynamicSelectField`)
70
94
  * 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.
95
+ * remote rows carry an `image` — instead of a raw FK text input. View mode
96
+ * shows the resolved thumbnail + label. Tolerates the snake_case
97
+ * `source`/`relation` aliases the manifest may serve.
73
98
  */
74
99
  ref?: string
75
100
  source?: string
@@ -88,9 +113,30 @@ interface FieldDef {
88
113
  // `ModelSchema` (see ./types.ts) is structurally assignable here.
89
114
  interface ModalMetadata {
90
115
  title?: string
116
+ /**
117
+ * i18n key for the model name (e.g. "accounting.model.account"). The backend
118
+ * can't always localize it (the addon bundle is only registered at install
119
+ * time), so it ships the key and we translate here — the frontend loads each
120
+ * addon's i18n live from the hub, so it resolves without a reinstall.
121
+ */
122
+ titleKey?: string
91
123
  createTitle?: string
92
124
  editTitle?: string
93
125
  fields?: FieldDef[]
126
+ /**
127
+ * Backend-localized CRUD success messages (modal metadata). Preferred over
128
+ * the raw response message which is not localized.
129
+ */
130
+ messages?: { created?: string; updated?: string; deleted?: string }
131
+ }
132
+
133
+ type TFn = (key: string) => string
134
+
135
+ // localizedModelName resolves the (possibly addon-i18n) model name: prefer the
136
+ // translated titleKey, fall back to the backend-provided raw title.
137
+ function localizedModelName(meta: ModalMetadata, t: TFn): string {
138
+ if (meta.titleKey && t(meta.titleKey) !== meta.titleKey) return t(meta.titleKey)
139
+ return meta.title || ''
94
140
  }
95
141
 
96
142
  export interface DynamicRecordDialogProps {
@@ -100,7 +146,10 @@ export interface DynamicRecordDialogProps {
100
146
  model: string
101
147
  recordId?: string | null
102
148
  endpoint?: string
103
- onSaved?: () => void
149
+ /** Fired after a successful save; receives the persisted record (when the
150
+ * backend returns it) so callers — e.g. the inline-create bridge behind a
151
+ * dynamic_select "+" — can auto-select the new row. */
152
+ onSaved?: (record?: any) => void
104
153
  /**
105
154
  * Optional override invoked instead of the default `POST` when the dialog
106
155
  * is in `create` mode. Hosts may use this to route writes through custom
@@ -133,28 +182,111 @@ export interface DynamicRecordDialogProps {
133
182
  * the action.
134
183
  */
135
184
  onEdit?: () => void
185
+ /**
186
+ * Deliberate escape hatch: open the full `/m/:model/:id` detail page (with
187
+ * cross-module related records) for records too heavy for the modal.
188
+ * Rendered as a footer link in view mode when provided.
189
+ */
190
+ onOpenFullPage?: () => void
191
+ /**
192
+ * The row object the table already loaded. When provided, the dialog renders
193
+ * instantly from it (no spinner) and reuses the table's pro siblings — the
194
+ * resolved relation (`row.category = {value,label}`), served option lists and
195
+ * image urls. A background fetch only fills in fields the list row omitted.
196
+ */
197
+ initialRecord?: Record<string, any> | null
198
+ /**
199
+ * Host resolver turning a (possibly relative) storage path into a fetchable
200
+ * URL for images/avatars/thumbnails. Defaults to identity. Pass the host's
201
+ * `getImageUrl` so addon-served relative paths render.
202
+ */
203
+ getImageUrl?: GetImageUrl
204
+ /**
205
+ * Org IANA timezone (e.g. `America/Mexico_City`). Threaded into the tz-aware
206
+ * `formatDateCell` so datetime/timestamp instants render in the org zone
207
+ * regardless of the viewer's browser timezone. Pure `date` values pin to UTC.
208
+ */
209
+ timeZone?: string
136
210
  }
137
211
 
138
212
  function resolvePath(obj: any, path: string): any {
139
213
  return path.split('.').reduce((acc, part) => acc?.[part], obj)
140
214
  }
141
215
 
216
+ // objectLabel pulls a human label off a resolved relation/user object the
217
+ // backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
218
+ // as created_by), or `{title}`. Returns undefined for plain/empty objects.
219
+ function objectLabel(value: any): string | undefined {
220
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
221
+ const label = value.label ?? value.name ?? value.title
222
+ if (label != null && label !== '') return String(label)
223
+ return undefined
224
+ }
225
+
226
+ // pickImage reads an image-ish path off a resolved object (FK sibling, user).
227
+ function pickImage(value: any): string | undefined {
228
+ if (!value || typeof value !== 'object') return undefined
229
+ const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail
230
+ return typeof img === 'string' && img !== '' ? img : undefined
231
+ }
232
+
233
+ // relationSiblingValue reads the resolved relation the table served alongside an
234
+ // FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
235
+ // `record.category = {value,label,image?}` (or a bare string/{name}); returns
236
+ // the raw sibling (object or string) so the caller can extract label + image.
237
+ function relationSiblingValue(field: FieldDef, record: any): any {
238
+ if (!record) return undefined
239
+ const candidates: string[] = []
240
+ const ref = getFieldRef(field as ActionFieldDef)
241
+ if (ref) candidates.push(ref)
242
+ if (typeof field.key === 'string' && field.key.endsWith('_id')) candidates.push(field.key.slice(0, -3))
243
+ for (const key of candidates) {
244
+ const sib = record[key]
245
+ if (sib === undefined || sib === null) continue
246
+ if (typeof sib === 'string') {
247
+ if (sib === '' || isNilUuid(sib)) continue
248
+ return sib
249
+ }
250
+ if (typeof sib === 'object') return sib
251
+ }
252
+ return undefined
253
+ }
254
+
255
+ // servedOption matches a field's served option list (enum/select with
256
+ // {value,label,color,icon,image}) against the current value.
257
+ function servedOption(field: FieldDef, value: any): FieldOption | undefined {
258
+ if (!field.options?.length) return undefined
259
+ return field.options.find(o => o.value === String(value ?? ''))
260
+ }
261
+
262
+ // createdBySibling reads the resolver object the backend serves for the
263
+ // auto-injected `created_by` avatar column: {name, avatar, email}.
264
+ function createdBySibling(value: any, record: any): { name?: string; avatar?: string; email?: string } | undefined {
265
+ const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by
266
+ if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email)) return obj
267
+ return undefined
268
+ }
269
+
270
+ // isRelationField — a field that resolves to another row (so view renders a lead
271
+ // + label and edit renders the searchable picker).
272
+ function isRelationField(field: FieldDef): boolean {
273
+ return (
274
+ field.type === 'search' ||
275
+ field.type === 'dynamic_select' ||
276
+ field.widget === 'dynamic_select' ||
277
+ !!getFieldRef(field as ActionFieldDef) ||
278
+ !!field.searchEndpoint
279
+ )
280
+ }
281
+
142
282
  function formatDisplayValue(rawValue: any, field: FieldDef): string {
143
283
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
144
284
  const value = normalizeNilUuid(rawValue)
145
285
  if (value === null || value === undefined || value === '') return '—'
286
+ const objLabel = objectLabel(value)
287
+ if (objLabel !== undefined) return objLabel
146
288
  if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No'
147
289
 
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
290
  if (field.type === 'select' && field.options?.length) {
159
291
  const match = field.options.find(o => o.value === String(value))
160
292
  // Matched option label wins (localized); humanize the raw token only
@@ -167,21 +299,27 @@ function formatDisplayValue(rawValue: any, field: FieldDef): string {
167
299
 
168
300
  const MODE_CONFIG = {
169
301
  create: {
170
- getTitle: (meta: ModalMetadata) => meta.createTitle || meta.title || 'Nuevo registro',
302
+ getTitle: (meta: ModalMetadata, t: TFn) => {
303
+ const name = localizedModelName(meta, t)
304
+ return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro')
305
+ },
171
306
  description: 'Completa los campos para crear un nuevo registro.',
172
307
  submitLabel: 'Crear',
173
308
  submittingLabel: 'Creando...',
174
309
  cancelLabel: 'Cancelar',
175
310
  },
176
311
  edit: {
177
- getTitle: (meta: ModalMetadata) => meta.editTitle || meta.title || 'Editar registro',
312
+ getTitle: (meta: ModalMetadata, t: TFn) => {
313
+ const name = localizedModelName(meta, t)
314
+ return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro')
315
+ },
178
316
  description: 'Modifica los campos y guarda los cambios.',
179
317
  submitLabel: 'Guardar cambios',
180
318
  submittingLabel: 'Guardando...',
181
319
  cancelLabel: 'Cancelar',
182
320
  },
183
321
  view: {
184
- getTitle: (meta: ModalMetadata) => meta.title || 'Ver registro',
322
+ getTitle: (meta: ModalMetadata, t: TFn) => localizedModelName(meta, t) || meta.title || 'Ver registro',
185
323
  description: 'Información detallada del registro.',
186
324
  submitLabel: '',
187
325
  submittingLabel: '',
@@ -189,7 +327,11 @@ const MODE_CONFIG = {
189
327
  },
190
328
  }
191
329
 
330
+ // Context threading host runtime values to nested field components (uploads,
331
+ // image leads, tz-aware dates) without prop-drilling through every renderer.
192
332
  const ModelContext = createContext('')
333
+ const ImageUrlContext = createContext<GetImageUrl>(identityImageUrl)
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>