@asteby/metacore-runtime-react 34.0.2 → 35.0.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +5 -84
  2. package/dist/action-modal-dispatcher.d.ts +4 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +102 -23
  5. package/dist/addon-loader.d.ts +1 -1
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +8 -1
  8. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  9. package/dist/dialogs/dynamic-record.js +107 -52
  10. package/dist/display-value.d.ts +23 -0
  11. package/dist/display-value.d.ts.map +1 -1
  12. package/dist/display-value.js +34 -1
  13. package/dist/dynamic-columns.d.ts +3 -0
  14. package/dist/dynamic-columns.d.ts.map +1 -1
  15. package/dist/dynamic-columns.js +51 -6
  16. package/dist/dynamic-select-field.d.ts.map +1 -1
  17. package/dist/dynamic-select-field.js +8 -9
  18. package/dist/dynamic-table.d.ts.map +1 -1
  19. package/dist/dynamic-table.js +10 -7
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/metadata-cache.d.ts.map +1 -1
  24. package/dist/metadata-cache.js +8 -1
  25. package/dist/types.d.ts +1 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/use-debounced-value.d.ts +8 -0
  28. package/dist/use-debounced-value.d.ts.map +1 -0
  29. package/dist/use-debounced-value.js +15 -0
  30. package/dist/use-dynamic-filters.d.ts.map +1 -1
  31. package/dist/use-dynamic-filters.js +6 -4
  32. package/dist/use-print-document.d.ts +10 -1
  33. package/dist/use-print-document.d.ts.map +1 -1
  34. package/dist/use-print-document.js +28 -1
  35. package/package.json +1 -1
  36. package/src/__tests__/filename-from-disposition.test.ts +30 -0
  37. package/src/__tests__/image-stack.test.tsx +52 -0
  38. package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
  39. package/src/__tests__/record-detail-display.test.tsx +30 -0
  40. package/src/__tests__/use-debounced-value.test.ts +56 -0
  41. package/src/action-modal-dispatcher.tsx +110 -22
  42. package/src/addon-loader.tsx +7 -1
  43. package/src/dialogs/dynamic-record.tsx +167 -51
  44. package/src/display-value.tsx +82 -4
  45. package/src/dynamic-columns.tsx +94 -5
  46. package/src/dynamic-select-field.tsx +9 -9
  47. package/src/dynamic-table.tsx +9 -6
  48. package/src/index.ts +7 -0
  49. package/src/metadata-cache.ts +8 -1
  50. package/src/types.ts +2 -0
  51. package/src/use-debounced-value.ts +17 -0
  52. package/src/use-dynamic-filters.ts +6 -4
  53. package/src/use-print-document.ts +38 -2
@@ -156,6 +156,65 @@ export function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<s
156
156
  return rows
157
157
  }
158
158
 
159
+ // ---- scalar prefill from the acted-on record --------------------------------
160
+ //
161
+ // Row actions (stamp / refactura / cancel-with-reason) should open with the
162
+ // current record's values, not empty selects. Manifest declares explicit paths
163
+ // via `defaultFromRecord` (string or string[] fallback chain). When omitted,
164
+ // the field seeds from record[field.key] if present.
165
+
166
+ export function unwrapRecordScalar(value: unknown): unknown {
167
+ if (value === null || value === undefined) return value
168
+ if (typeof value !== 'object' || value instanceof Date) return value
169
+ if (Array.isArray(value)) return value
170
+ const o = value as Record<string, unknown>
171
+ if ('value' in o && (typeof o.value === 'string' || typeof o.value === 'number')) {
172
+ return o.value
173
+ }
174
+ if ('id' in o && (typeof o.id === 'string' || typeof o.id === 'number')) {
175
+ return o.id
176
+ }
177
+ return value
178
+ }
179
+
180
+ export function readRecordPath(record: any, path: string): unknown {
181
+ if (!record || !path) return undefined
182
+ const parts = path.split('.')
183
+ let cur: any = record
184
+ for (const p of parts) {
185
+ if (cur == null || typeof cur !== 'object') return undefined
186
+ cur = cur[p]
187
+ }
188
+ return unwrapRecordScalar(cur)
189
+ }
190
+
191
+ function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undefined {
192
+ const f = field as ActionFieldDef & {
193
+ defaultFromRecord?: string | string[]
194
+ default_from_record?: string | string[]
195
+ }
196
+ return f.defaultFromRecord ?? f.default_from_record
197
+ }
198
+
199
+ /** Scalar seed for one action field from the row being acted on. */
200
+ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unknown {
201
+ if (!record) return undefined
202
+ const spec = defaultFromRecordSpec(field)
203
+ if (typeof spec === 'string') {
204
+ const v = readRecordPath(record, spec)
205
+ if (v !== undefined && v !== null && v !== '') return v
206
+ } else if (Array.isArray(spec)) {
207
+ for (const path of spec) {
208
+ const v = readRecordPath(record, path)
209
+ if (v !== undefined && v !== null && v !== '') return v
210
+ }
211
+ } else if (field.key) {
212
+ const v = readRecordPath(record, field.key)
213
+ if (v !== undefined && v !== null && v !== '') return v
214
+ }
215
+ return undefined
216
+ }
217
+
159
218
  export function ActionModalDispatcher({
160
219
  open,
161
220
  onOpenChange,
@@ -322,14 +381,17 @@ function selectPreviewColumns(columns: ColumnDefinition[] | undefined, record: a
322
381
  continue
323
382
  }
324
383
 
325
- // Relación (ref / search / dynamic_select / *_id): solo si el sibling
326
- // resolvió a un label legible; si es un *_id crudo sin resolver, se omite.
384
+ // Relación (ref / search / dynamic_select / uuid *_id): solo si el sibling
385
+ // resolvió a un label legible; text `*_id` (external_id) no es FK.
386
+ const t = String(col.type || '').toLowerCase()
327
387
  const isRelation =
328
388
  !!getFieldRef(col as ActionFieldDef) ||
329
389
  col.type === 'search' ||
330
390
  col.type === 'relation' ||
331
391
  (col as { widget?: string }).widget === 'dynamic_select' ||
332
- (typeof col.key === 'string' && col.key.endsWith('_id'))
392
+ (typeof col.key === 'string' &&
393
+ col.key.endsWith('_id') &&
394
+ (t === 'uuid' || t === 'search' || t === 'relation' || t === 'dynamic_select' || t === 'belongs_to'))
333
395
  if (isRelation) {
334
396
  const sib = relationSiblingValue(col as any, record)
335
397
  const label = typeof sib === 'string' ? sib : objectLabel(sib)
@@ -552,20 +614,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
552
614
 
553
615
  useEffect(() => {
554
616
  if (open && action.fields) {
555
- const defaults: Record<string, any> = {}
556
- for (const field of action.fields) {
557
- if (isLineItemsField(field)) {
558
- const dv = lineItemsDefault(field)
559
- defaults[field.key] = isPrefillSpec(dv)
560
- ? buildPrefillRows(dv, record)
561
- : Array.isArray(dv)
562
- ? dv
563
- : []
564
- continue
565
- }
566
- defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
567
- }
568
- setFormData(defaults)
617
+ setFormData(buildFieldDefaults(action.fields, record))
569
618
  setFieldErrors({})
570
619
  }
571
620
  }, [open, action.fields, record])
@@ -632,6 +681,8 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
632
681
  () => (action.fields ?? []).some(isLineItemsField),
633
682
  [action.fields],
634
683
  )
684
+ const embedRelations =
685
+ hasLineItems || !!(action as ActionMetadata & { embedRelations?: boolean }).embedRelations
635
686
  const explicitWidth = (action as unknown as { modalWidth?: number | string }).modalWidth
636
687
  const widthPx =
637
688
  explicitWidth != null
@@ -657,7 +708,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
657
708
  <DynamicIcon name={action.icon} className="h-5 w-5" />
658
709
  {tl(action.label)}
659
710
  </DialogTitle>
660
- {action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
711
+ {action.confirmMessage && <DialogDescription>{tl(action.confirmMessage)}</DialogDescription>}
661
712
  </DialogHeader>
662
713
  {/* Scrollable body. The shared FieldGrid lays scalar fields out
663
714
  in two responsive columns (single column on phones); line-items
@@ -676,14 +727,14 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
676
727
  <FieldLabel htmlFor={field.key} required={field.required}>
677
728
  {tl(field.label)}
678
729
  </FieldLabel>
679
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
730
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)}
680
731
  {fieldErrors[field.key] && (
681
732
  <p className="text-destructive text-xs mt-1">{fieldErrors[field.key]}</p>
682
733
  )}
683
734
  </FieldCell>
684
735
  )
685
736
  })}
686
- {relations.length > 0 && (
737
+ {embedRelations && relations.length > 0 && (
687
738
  <FieldCell fullWidth>
688
739
  {/* Igual que el modal de registro: solo las
689
740
  relaciones de composición se embeben. */}
@@ -725,6 +776,11 @@ function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<strin
725
776
  : []
726
777
  continue
727
778
  }
779
+ const fromRecord = scalarDefaultFromRecord(field, record)
780
+ if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') {
781
+ defaults[field.key] = fromRecord
782
+ continue
783
+ }
728
784
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
729
785
  }
730
786
  return defaults
@@ -881,7 +937,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
881
937
  <FieldLabel htmlFor={field.key} required={field.required}>
882
938
  {tl(field.label)}
883
939
  </FieldLabel>
884
- {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
940
+ {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)}
885
941
  </FieldCell>
886
942
  )
887
943
  })}
@@ -922,6 +978,29 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
922
978
  )
923
979
  }
924
980
 
981
+ function seedOptionFromRecord(
982
+ field: ActionFieldDef,
983
+ value: any,
984
+ record?: Record<string, any>,
985
+ ): import('./use-options-resolver').ResolvedOption | undefined {
986
+ if (!record || !field.key.endsWith('_id')) return undefined
987
+ const siblingKey = field.key.replace(/_id$/, '')
988
+ const sib = record[siblingKey]
989
+ if (!sib || typeof sib !== 'object') return undefined
990
+ const label = (sib as any).label ?? (sib as any).name ?? ''
991
+ if (!label && !(sib as any).image) return undefined
992
+ const id = String((sib as any).value ?? (sib as any).id ?? value ?? '')
993
+ return {
994
+ id,
995
+ value: id,
996
+ label: String(label),
997
+ name: String(label),
998
+ image: (sib as any).image,
999
+ color: (sib as any).color,
1000
+ icon: (sib as any).icon,
1001
+ }
1002
+ }
1003
+
925
1004
  function renderField(
926
1005
  field: ActionFieldDef,
927
1006
  value: any,
@@ -931,6 +1010,7 @@ function renderField(
931
1010
  // fields. Omitted by callers that have no surrounding form (the field is
932
1011
  // then treated as having no resolvable dependency).
933
1012
  formValues?: Record<string, any>,
1013
+ record?: Record<string, any>,
934
1014
  ) {
935
1015
  // Repeatable line-items group → row grid (value is an array of row objects).
936
1016
  // The header form values flow in so a cell can depend on a header field.
@@ -948,7 +1028,15 @@ function renderField(
948
1028
  const dependsValue = getDependsOn(field)
949
1029
  ? resolveDependsValue(field, formValues)
950
1030
  : undefined
951
- return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} />
1031
+ return (
1032
+ <DynamicSelectField
1033
+ field={field}
1034
+ value={value}
1035
+ onChange={onChange}
1036
+ dependsValue={dependsValue}
1037
+ seedOption={seedOptionFromRecord(field, value, record)}
1038
+ />
1039
+ )
952
1040
  }
953
1041
  // File upload → themed picker that POSTs the file to the host upload
954
1042
  // endpoint and stores the returned url/path. Kept in sync with DynamicForm.
@@ -199,7 +199,13 @@ export function AddonLoader({
199
199
  }, [scope, url, module, addonKey, unbindKey, hostRegistry])
200
200
 
201
201
  if (status === 'loading') return <>{fallback}</>
202
- if (status === 'error')
202
+ if (status === 'error') {
203
+ // Hosts that pass `onError` (toast + telemetry) should not also paint
204
+ // a second, persistent inline banner — DynamicAddonLoaders mounts one
205
+ // fiber per installed addon and a visible error stack reads as a broken
206
+ // shell even when only one remote failed transiently.
207
+ if (onError) return null
203
208
  return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
209
+ }
204
210
  return <>{children}</>
205
211
  }
@@ -9,7 +9,7 @@
9
9
  // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
10
10
  // the image-url resolver and the org IANA timezone — are passed as props so the
11
11
  // SDK stays transport- and host-agnostic.
12
- import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
12
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
13
13
  import { useTranslation } from 'react-i18next'
14
14
  import type { ModelSchema } from './types'
15
15
 
@@ -53,9 +53,7 @@ import { es } from 'date-fns/locale'
53
53
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
54
54
  import { BarcodeScanner } from '../barcode-scanner'
55
55
  import { useApi } from '../api-context'
56
- import { toastServerError, extractFieldErrors, localizeFieldErrorMap, type Translate } from '../server-error'
57
- import { validateValues, bagHasErrors } from '../validator'
58
- import { validationCatalog } from '../validation-catalog'
56
+ import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
59
57
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
60
58
  import { DynamicRelations } from '../dynamic-relations'
61
59
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
@@ -71,6 +69,7 @@ import { IconPickerField } from '../icon-picker-field'
71
69
  import { humanizeToken } from '../dynamic-columns-helpers'
72
70
  import { formatDateCell } from '../dynamic-columns'
73
71
  import {
72
+ ImageStack,
74
73
  OptionBadge,
75
74
  statusColorFor,
76
75
  useIsDarkTheme,
@@ -420,6 +419,24 @@ function isRelationField(field: FieldDef): boolean {
420
419
  )
421
420
  }
422
421
 
422
+ // looksLikeForeignKey — true only for real FKs. A bare `*_id` suffix is NOT
423
+ // enough: columns like `external_id`, `trace_id`, or `invoice_uid` are plain
424
+ // text identifiers from a PAC/provider, not belongs_to relations. Treating them
425
+ // as relations rendered an InitialsAvatar ("6" chip next to "6a8c…") and made
426
+ // fiscal detail modals look broken.
427
+ function looksLikeForeignKey(field: FieldDef): boolean {
428
+ if (isRelationField(field)) return true
429
+ if (typeof field.key !== 'string' || !field.key.endsWith('_id')) return false
430
+ const t = String(field.type || '').toLowerCase()
431
+ return (
432
+ t === 'uuid' ||
433
+ t === 'search' ||
434
+ t === 'relation' ||
435
+ t === 'dynamic_select' ||
436
+ t === 'belongs_to'
437
+ )
438
+ }
439
+
423
440
  function formatDisplayValue(rawValue: any, field: FieldDef): string {
424
441
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
425
442
  const value = normalizeNilUuid(rawValue)
@@ -543,12 +560,6 @@ export function stripHiddenFieldValues(
543
560
  return out
544
561
  }
545
562
 
546
- function toastValidationFailed(t: Translate, lang: string, localized: Record<string, string>) {
547
- toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
548
- description: Object.values(localized).filter(Boolean).join('\n'),
549
- })
550
- }
551
-
552
563
  export function DynamicRecordDialog({
553
564
  open,
554
565
  onOpenChange,
@@ -572,12 +583,7 @@ export function DynamicRecordDialog({
572
583
  onChange,
573
584
  }: DynamicRecordDialogProps) {
574
585
  const api = useApi()
575
- const { t, i18n } = useTranslation()
576
- // Unique per dialog instance. The footer submit lives OUTSIDE <form>, so
577
- // it binds via `form={id}`. A hardcoded id made nested create (product +
578
- // "Crear categoría") submit the PARENT form — toast "Revisa los campos
579
- // marcados" with no marks on the inner modal.
580
- const formId = useId()
586
+ const { t } = useTranslation()
581
587
  const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
582
588
  schema ? (schema as ModalMetadata) : null,
583
589
  )
@@ -778,26 +784,25 @@ export function DynamicRecordDialog({
778
784
  // with no matching form field).
779
785
  const labelForKey = (key: string): string => {
780
786
  const f = (modalMeta?.fields ?? []).find(x => x.key === key)
781
- if (f?.label) return t(f.label, { defaultValue: f.label })
787
+ if (f?.label) return f.label
782
788
  return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
783
789
  }
784
790
 
785
- const lang = i18n.language
786
-
787
791
  // Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
788
792
  // inline field errors + a summary toast. When there is no field map, fall
789
793
  // back to the existing single cause-carrying toast.
790
794
  const handleSubmitError = (err: unknown) => {
791
795
  const map = extractFieldErrors(err)
792
796
  if (map) {
793
- const labels: Record<string, string> = {}
794
- for (const f of modalMeta?.fields ?? []) labels[f.key] = labelForKey(f.key)
795
- const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
796
- setFieldErrors(localized)
797
- toastValidationFailed(t, lang, localized)
797
+ const next: Record<string, string> = {}
798
+ for (const [key, issues] of Object.entries(map)) {
799
+ next[key] = localizeFieldIssue(issues[0], labelForKey(key), t)
800
+ }
801
+ setFieldErrors(next)
802
+ toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
798
803
  return
799
804
  }
800
- toastServerError(err, { t, language: lang, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
805
+ toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) })
801
806
  }
802
807
 
803
808
  const handleSubmit = async (e?: React.FormEvent) => {
@@ -810,14 +815,15 @@ export function DynamicRecordDialog({
810
815
  // fields are gated: a field hidden by its `visible_when` predicate
811
816
  // must not block submit even when it is declared required (matching
812
817
  // the render, which drops it via the same filter).
813
- const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
814
- const bag = validateValues(visible as ActionFieldDef[], formValues)
815
- if (bagHasErrors(bag)) {
816
- const labels: Record<string, string> = {}
817
- for (const f of visible) labels[f.key] = t(f.label, { defaultValue: f.label })
818
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
819
- setFieldErrors(localized)
820
- toastValidationFailed(t, lang, localized)
818
+ const missing: Record<string, string> = {}
819
+ for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
820
+ if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
821
+ missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
822
+ }
823
+ }
824
+ if (Object.keys(missing).length) {
825
+ setFieldErrors(missing)
826
+ toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
821
827
  return
822
828
  }
823
829
  }
@@ -959,13 +965,15 @@ export function DynamicRecordDialog({
959
965
  // then advance. Mirrors handleSubmit's required check but scoped to the step.
960
966
  const goNextStep = () => {
961
967
  const step = groups[clampedStep]
962
- const bag = validateValues((step?.fields ?? []) as ActionFieldDef[], formValues)
963
- if (bagHasErrors(bag)) {
964
- const labels: Record<string, string> = {}
965
- for (const f of step?.fields ?? []) labels[f.key] = t(f.label, { defaultValue: f.label })
966
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
967
- setFieldErrors(localized)
968
- toastValidationFailed(t, lang, localized)
968
+ const missing: Record<string, string> = {}
969
+ for (const field of step?.fields ?? []) {
970
+ if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
971
+ missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
972
+ }
973
+ }
974
+ if (Object.keys(missing).length) {
975
+ setFieldErrors(missing)
976
+ toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
969
977
  return
970
978
  }
971
979
  setFieldErrors({})
@@ -998,7 +1006,7 @@ export function DynamicRecordDialog({
998
1006
  cell `min-w-0` so a long select/input value can't
999
1007
  blow the two columns past the dialog width. */}
1000
1008
  <form
1001
- id={formId}
1009
+ id="dynamic-record-form"
1002
1010
  onSubmit={handleSubmit}
1003
1011
  className="grid gap-y-4"
1004
1012
  >
@@ -1108,7 +1116,7 @@ export function DynamicRecordDialog({
1108
1116
  {isEditable && (!isSteps || isLastStep) && (
1109
1117
  <Button
1110
1118
  type="submit"
1111
- form={formId}
1119
+ form="dynamic-record-form"
1112
1120
  disabled={saving || loading}
1113
1121
  >
1114
1122
  {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1212,9 +1220,7 @@ export function ReadonlyEditField({ field, value }: { field: FieldDef; value: an
1212
1220
  // ReadonlyRelationField — a locked/readonly FK field (customer_id, category_id…)
1213
1221
  // resolves the record's label instead of showing the raw id, mirroring
1214
1222
  // RelationViewValue's lookup but rendered as a disabled input to match the rest
1215
- // of ReadonlyEditField. Without this, a locked relation field (e.g. `lockedFields`
1216
- // seeding a POS-selected customer into a vehicle create modal) would show a bare
1217
- // UUID — the exact readability bug this dialog otherwise avoids elsewhere.
1223
+ // of ReadonlyEditField.
1218
1224
  function ReadonlyRelationField({
1219
1225
  field,
1220
1226
  value,
@@ -1243,7 +1249,19 @@ function ReadonlyRelationField({
1243
1249
  // RelationViewValue — read-only FK lead. Resolves the relation's label + image
1244
1250
  // from (1) the sibling object the table served, then (2) the canonical options
1245
1251
  // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
1246
- function RelationViewValue({ field, value, record }: { field: FieldDef; value: any; record: any }) {
1252
+ // When `stack` is true (`display: "image_stack"`), the landscape mark sits ON
1253
+ // TOP of the label — wide logos (brand marks) fit without cropping.
1254
+ function RelationViewValue({
1255
+ field,
1256
+ value,
1257
+ record,
1258
+ stack = false,
1259
+ }: {
1260
+ field: FieldDef
1261
+ value: any
1262
+ record: any
1263
+ stack?: boolean
1264
+ }) {
1247
1265
  const getImageUrl = useContext(ImageUrlContext)
1248
1266
  const sib = relationSiblingValue(field, record)
1249
1267
  const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
@@ -1278,6 +1296,19 @@ function RelationViewValue({ field, value, record }: { field: FieldDef; value: a
1278
1296
  return <p className="text-sm py-1 text-muted-foreground">—</p>
1279
1297
  }
1280
1298
 
1299
+ if (stack) {
1300
+ return (
1301
+ <div className="py-1">
1302
+ <ImageStack
1303
+ src={image || undefined}
1304
+ label={label}
1305
+ getImageUrl={getImageUrl}
1306
+ size="lg"
1307
+ />
1308
+ </div>
1309
+ )
1310
+ }
1311
+
1281
1312
  const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon' | 'label'> = {
1282
1313
  image: image ? getImageUrl(image) : null,
1283
1314
  color: resolved?.color ?? null,
@@ -1368,10 +1399,16 @@ export function ViewValue({
1368
1399
 
1369
1400
  const value = normalizeNilUuid(rawValue)
1370
1401
 
1371
- // Relation (search / dynamic_select / ref / any *_id) resolved thumbnail +
1372
- // label. The *_id catch-all covers plain-typed FK columns not tagged as a
1373
- // relation field.
1374
- if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
1402
+ // Landscape stack on a relation FK (brand marks, product cards): image ON
1403
+ // TOP, label UNDERNEATH. Checked before the default relation lead so a
1404
+ // `display: "image_stack"` FK does not fall through to the side-by-side chip.
1405
+ if (renderAs === 'image_stack' && looksLikeForeignKey(field)) {
1406
+ return <RelationViewValue field={field} value={value} record={record} stack />
1407
+ }
1408
+
1409
+ // Relation (search / dynamic_select / ref / uuid *_id FK) → resolved
1410
+ // thumbnail + label. Plain text `*_id` columns (external_id, …) stay text.
1411
+ if (looksLikeForeignKey(field)) {
1375
1412
  return <RelationViewValue field={field} value={value} record={record} />
1376
1413
  }
1377
1414
 
@@ -1404,7 +1441,36 @@ export function ViewValue({
1404
1441
  )
1405
1442
  }
1406
1443
 
1407
- if (field.type === 'image') {
1444
+ // Landscape stack for image/logo URL columns (and `type: image` with
1445
+ // `cellStyle: image_stack`). Wide marks sit above an optional caption.
1446
+ if (renderAs === 'image_stack') {
1447
+ if (value && isLucideIconName(value)) {
1448
+ return <IconNameViewValue name={value} />
1449
+ }
1450
+ const labelField =
1451
+ (field.styleConfig &&
1452
+ (field.styleConfig.label_field as string | undefined)) ||
1453
+ (field.styleConfig && (field.styleConfig.labelField as string | undefined))
1454
+ let caption: string | undefined
1455
+ if (labelField && record && typeof record === 'object') {
1456
+ const raw = (record as Record<string, unknown>)[labelField]
1457
+ if (raw != null && String(raw) !== '') caption = String(raw)
1458
+ }
1459
+ return value || caption ? (
1460
+ <div className="py-1">
1461
+ <ImageStack
1462
+ src={value ? String(value) : undefined}
1463
+ label={caption}
1464
+ getImageUrl={getImageUrl}
1465
+ size="lg"
1466
+ />
1467
+ </div>
1468
+ ) : (
1469
+ <p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
1470
+ )
1471
+ }
1472
+
1473
+ if (field.type === 'image' || renderAs === 'image') {
1408
1474
  if (isLucideIconName(value)) {
1409
1475
  return <IconNameViewValue name={value} />
1410
1476
  }
@@ -1698,6 +1764,19 @@ function StructuredViewValue({
1698
1764
  if (isEmpty) {
1699
1765
  return <p className="text-sm py-1 text-muted-foreground">—</p>
1700
1766
  }
1767
+ // Line-items arrays with a declared itemFields schema → mini-table.
1768
+ // Plain objects (PAC provider_data, fiscal_data bags) → readable key/value
1769
+ // list; nested objects/arrays render as pretty JSON instead of `key: {…}`
1770
+ // stubs that looked broken in fiscal detail modals.
1771
+ const hasItemFields = !!(field?.itemFields ?? field?.item_fields)
1772
+ if (
1773
+ !hasItemFields &&
1774
+ value !== null &&
1775
+ typeof value === 'object' &&
1776
+ !Array.isArray(value)
1777
+ ) {
1778
+ return <JsonObjectViewValue value={value as Record<string, unknown>} />
1779
+ }
1701
1780
  return (
1702
1781
  <div className="text-sm py-1">
1703
1782
  <CollectionCell
@@ -1712,6 +1791,43 @@ function StructuredViewValue({
1712
1791
  )
1713
1792
  }
1714
1793
 
1794
+ /** Flatten a jsonb/object bag into labeled rows; nest as pretty JSON. */
1795
+ function JsonObjectViewValue({ value }: { value: Record<string, unknown> }) {
1796
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined)
1797
+ if (entries.length === 0) {
1798
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
1799
+ }
1800
+ return (
1801
+ <dl className="grid gap-2 py-1 text-sm">
1802
+ {entries.map(([key, raw]) => {
1803
+ const label = humanizeToken(key)
1804
+ const isNest =
1805
+ raw !== null &&
1806
+ typeof raw === 'object' &&
1807
+ !(raw instanceof Date)
1808
+ return (
1809
+ <div key={key} className="min-w-0">
1810
+ <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1811
+ {label}
1812
+ </dt>
1813
+ <dd className="mt-0.5 break-words text-foreground">
1814
+ {isNest ? (
1815
+ <pre className="max-h-48 overflow-auto rounded-md bg-muted/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap">
1816
+ {JSON.stringify(raw, null, 2)}
1817
+ </pre>
1818
+ ) : raw === null || raw === '' ? (
1819
+ <span className="text-muted-foreground">—</span>
1820
+ ) : (
1821
+ String(raw)
1822
+ )}
1823
+ </dd>
1824
+ </div>
1825
+ )
1826
+ })}
1827
+ </dl>
1828
+ )
1829
+ }
1830
+
1715
1831
  export function EditField({ field, value, onChange, record }: {
1716
1832
  field: FieldDef
1717
1833
  value: any