@asteby/metacore-runtime-react 34.1.0 → 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.
@@ -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'
@@ -421,6 +419,24 @@ function isRelationField(field: FieldDef): boolean {
421
419
  )
422
420
  }
423
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
+
424
440
  function formatDisplayValue(rawValue: any, field: FieldDef): string {
425
441
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
426
442
  const value = normalizeNilUuid(rawValue)
@@ -544,12 +560,6 @@ export function stripHiddenFieldValues(
544
560
  return out
545
561
  }
546
562
 
547
- function toastValidationFailed(t: Translate, lang: string, localized: Record<string, string>) {
548
- toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
549
- description: Object.values(localized).filter(Boolean).join('\n'),
550
- })
551
- }
552
-
553
563
  export function DynamicRecordDialog({
554
564
  open,
555
565
  onOpenChange,
@@ -573,12 +583,7 @@ export function DynamicRecordDialog({
573
583
  onChange,
574
584
  }: DynamicRecordDialogProps) {
575
585
  const api = useApi()
576
- const { t, i18n } = useTranslation()
577
- // Unique per dialog instance. The footer submit lives OUTSIDE <form>, so
578
- // it binds via `form={id}`. A hardcoded id made nested create (product +
579
- // "Crear categoría") submit the PARENT form — toast "Revisa los campos
580
- // marcados" with no marks on the inner modal.
581
- const formId = useId()
586
+ const { t } = useTranslation()
582
587
  const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
583
588
  schema ? (schema as ModalMetadata) : null,
584
589
  )
@@ -779,26 +784,25 @@ export function DynamicRecordDialog({
779
784
  // with no matching form field).
780
785
  const labelForKey = (key: string): string => {
781
786
  const f = (modalMeta?.fields ?? []).find(x => x.key === key)
782
- if (f?.label) return t(f.label, { defaultValue: f.label })
787
+ if (f?.label) return f.label
783
788
  return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
784
789
  }
785
790
 
786
- const lang = i18n.language
787
-
788
791
  // Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
789
792
  // inline field errors + a summary toast. When there is no field map, fall
790
793
  // back to the existing single cause-carrying toast.
791
794
  const handleSubmitError = (err: unknown) => {
792
795
  const map = extractFieldErrors(err)
793
796
  if (map) {
794
- const labels: Record<string, string> = {}
795
- for (const f of modalMeta?.fields ?? []) labels[f.key] = labelForKey(f.key)
796
- const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
797
- setFieldErrors(localized)
798
- 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' }))
799
803
  return
800
804
  }
801
- 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' }) })
802
806
  }
803
807
 
804
808
  const handleSubmit = async (e?: React.FormEvent) => {
@@ -811,14 +815,15 @@ export function DynamicRecordDialog({
811
815
  // fields are gated: a field hidden by its `visible_when` predicate
812
816
  // must not block submit even when it is declared required (matching
813
817
  // the render, which drops it via the same filter).
814
- const visible = filterVisibleFields(modalMeta.fields, mode, formValues)
815
- const bag = validateValues(visible as ActionFieldDef[], formValues)
816
- if (bagHasErrors(bag)) {
817
- const labels: Record<string, string> = {}
818
- for (const f of visible) labels[f.key] = t(f.label, { defaultValue: f.label })
819
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
820
- setFieldErrors(localized)
821
- 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' }))
822
827
  return
823
828
  }
824
829
  }
@@ -960,13 +965,15 @@ export function DynamicRecordDialog({
960
965
  // then advance. Mirrors handleSubmit's required check but scoped to the step.
961
966
  const goNextStep = () => {
962
967
  const step = groups[clampedStep]
963
- const bag = validateValues((step?.fields ?? []) as ActionFieldDef[], formValues)
964
- if (bagHasErrors(bag)) {
965
- const labels: Record<string, string> = {}
966
- for (const f of step?.fields ?? []) labels[f.key] = t(f.label, { defaultValue: f.label })
967
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
968
- setFieldErrors(localized)
969
- 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' }))
970
977
  return
971
978
  }
972
979
  setFieldErrors({})
@@ -999,7 +1006,7 @@ export function DynamicRecordDialog({
999
1006
  cell `min-w-0` so a long select/input value can't
1000
1007
  blow the two columns past the dialog width. */}
1001
1008
  <form
1002
- id={formId}
1009
+ id="dynamic-record-form"
1003
1010
  onSubmit={handleSubmit}
1004
1011
  className="grid gap-y-4"
1005
1012
  >
@@ -1109,7 +1116,7 @@ export function DynamicRecordDialog({
1109
1116
  {isEditable && (!isSteps || isLastStep) && (
1110
1117
  <Button
1111
1118
  type="submit"
1112
- form={formId}
1119
+ form="dynamic-record-form"
1113
1120
  disabled={saving || loading}
1114
1121
  >
1115
1122
  {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -1213,9 +1220,7 @@ export function ReadonlyEditField({ field, value }: { field: FieldDef; value: an
1213
1220
  // ReadonlyRelationField — a locked/readonly FK field (customer_id, category_id…)
1214
1221
  // resolves the record's label instead of showing the raw id, mirroring
1215
1222
  // RelationViewValue's lookup but rendered as a disabled input to match the rest
1216
- // of ReadonlyEditField. Without this, a locked relation field (e.g. `lockedFields`
1217
- // seeding a POS-selected customer into a vehicle create modal) would show a bare
1218
- // UUID — the exact readability bug this dialog otherwise avoids elsewhere.
1223
+ // of ReadonlyEditField.
1219
1224
  function ReadonlyRelationField({
1220
1225
  field,
1221
1226
  value,
@@ -1244,7 +1249,19 @@ function ReadonlyRelationField({
1244
1249
  // RelationViewValue — read-only FK lead. Resolves the relation's label + image
1245
1250
  // from (1) the sibling object the table served, then (2) the canonical options
1246
1251
  // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
1247
- function RelationViewValue({ field, value, record, stack = false }: { field: FieldDef; value: any; record: any; stack?: boolean }) {
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
+ }) {
1248
1265
  const getImageUrl = useContext(ImageUrlContext)
1249
1266
  const sib = relationSiblingValue(field, record)
1250
1267
  const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
@@ -1382,18 +1399,16 @@ export function ViewValue({
1382
1399
 
1383
1400
  const value = normalizeNilUuid(rawValue)
1384
1401
 
1385
- // Landscape stack on a relation FK (brand marks, product cards).
1386
- if (
1387
- renderAs === 'image_stack' &&
1388
- (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id')))
1389
- ) {
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)) {
1390
1406
  return <RelationViewValue field={field} value={value} record={record} stack />
1391
1407
  }
1392
1408
 
1393
- // Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
1394
- // label. The *_id catch-all covers plain-typed FK columns not tagged as a
1395
- // relation field.
1396
- if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
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)) {
1397
1412
  return <RelationViewValue field={field} value={value} record={record} />
1398
1413
  }
1399
1414
 
@@ -1426,27 +1441,36 @@ export function ViewValue({
1426
1441
  )
1427
1442
  }
1428
1443
 
1429
- // Landscape stack for image/logo URL columns.
1444
+ // Landscape stack for image/logo URL columns (and `type: image` with
1445
+ // `cellStyle: image_stack`). Wide marks sit above an optional caption.
1430
1446
  if (renderAs === 'image_stack') {
1431
- const caption =
1432
- (typeof field.styleConfig?.label_field === 'string' &&
1433
- record?.[field.styleConfig.label_field]) ||
1434
- (typeof field.styleConfig?.labelField === 'string' &&
1435
- record?.[field.styleConfig.labelField]) ||
1436
- undefined
1437
- return (
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 ? (
1438
1460
  <div className="py-1">
1439
1461
  <ImageStack
1440
1462
  src={value ? String(value) : undefined}
1441
- label={caption ? String(caption) : field.label}
1463
+ label={caption}
1442
1464
  getImageUrl={getImageUrl}
1443
1465
  size="lg"
1444
1466
  />
1445
1467
  </div>
1468
+ ) : (
1469
+ <p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
1446
1470
  )
1447
1471
  }
1448
1472
 
1449
- if (field.type === 'image') {
1473
+ if (field.type === 'image' || renderAs === 'image') {
1450
1474
  if (isLucideIconName(value)) {
1451
1475
  return <IconNameViewValue name={value} />
1452
1476
  }
@@ -1740,6 +1764,19 @@ function StructuredViewValue({
1740
1764
  if (isEmpty) {
1741
1765
  return <p className="text-sm py-1 text-muted-foreground">—</p>
1742
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
+ }
1743
1780
  return (
1744
1781
  <div className="text-sm py-1">
1745
1782
  <CollectionCell
@@ -1754,6 +1791,43 @@ function StructuredViewValue({
1754
1791
  )
1755
1792
  }
1756
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
+
1757
1831
  export function EditField({ field, value, onChange, record }: {
1758
1832
  field: FieldDef
1759
1833
  value: any
@@ -44,9 +44,10 @@ import {
44
44
  import { Progress } from './dialogs/_primitives'
45
45
  import { humanizeToken } from './dynamic-columns-helpers'
46
46
  import { objectLabel } from './dynamic-relation-helpers'
47
- import { ImageStack,
47
+ import {
48
48
  OptionBadge,
49
49
  RelationThumbnail,
50
+ ImageStack,
50
51
  statusColorFor,
51
52
  useIsDarkTheme,
52
53
  } from './display-value'
@@ -603,6 +604,10 @@ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string
603
604
  * carries an `image`. Falls back to the raw id when no sibling was resolved, and
604
605
  * to an empty marker when there is no value at all. Domain-agnostic: works for
605
606
  * every `belongs_to` column (category, supplier, brand, …) without per-addon code.
607
+ *
608
+ * When `stack` is true (column `display: "image_stack"`), the landscape mark
609
+ * sits ON TOP of the label — wide logos fit without cropping and the cell
610
+ * stays readable in dense tables.
606
611
  */
607
612
  const RelationCell: React.FC<{
608
613
  col: ColumnDefinition
@@ -944,7 +949,15 @@ export function makeDefaultGetDynamicColumns(
944
949
  }
945
950
 
946
951
  // Landscape stack: wide image ON TOP, label UNDERNEATH.
952
+ // Declared via `display: "image_stack"` on an image column
953
+ // or on a belongs_to FK whose sibling carries a logo/photo
954
+ // (brand marks, product cards). Fits logos that are wider
955
+ // than tall without cropping into a square thumb.
947
956
  if (renderAs === 'image_stack') {
957
+ // FK relation (brand_id → brands) OR any column that
958
+ // already resolved a sibling with an image — stack it.
959
+ // Don't require `col.ref` alone: enrichment sometimes
960
+ // leaves type=text while cellStyle carries image_stack.
948
961
  const looksRelation =
949
962
  !!col.ref ||
950
963
  (typeof col.key === 'string' &&
@@ -1332,30 +1345,33 @@ export function makeDefaultGetDynamicColumns(
1332
1345
  )
1333
1346
  }
1334
1347
 
1335
- case 'image_stack': {
1348
+ case 'image': {
1336
1349
  const imageValue =
1337
1350
  value ||
1338
1351
  (Array.isArray(row.original.media)
1339
1352
  ? row.original.media.find((m: any) => m.type === 'image')?.url
1340
1353
  : null)
1354
+ return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1355
+ }
1356
+
1357
+ case 'image_stack': {
1358
+ // Defensive: normally handled above before the
1359
+ // switch; kept so a late `type: image_stack` without
1360
+ // cellStyle still stacks.
1361
+ const labelField = styleCfg(col, 'label_field', 'labelField')
1362
+ const caption = labelField
1363
+ ? String(getNestedValue(row.original, labelField) ?? '')
1364
+ : undefined
1341
1365
  return (
1342
1366
  <ImageCell
1343
- value={imageValue}
1367
+ value={value}
1344
1368
  getImageUrl={getImageUrl}
1369
+ label={caption || undefined}
1345
1370
  stack
1346
1371
  />
1347
1372
  )
1348
1373
  }
1349
1374
 
1350
- case 'image': {
1351
- const imageValue =
1352
- value ||
1353
- (Array.isArray(row.original.media)
1354
- ? row.original.media.find((m: any) => m.type === 'image')?.url
1355
- : null)
1356
- return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1357
- }
1358
-
1359
1375
  default: {
1360
1376
  if (typeof value === 'object' && value !== null) {
1361
1377
  return (