@asteby/metacore-runtime-react 34.1.0 → 35.1.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 (43) hide show
  1. package/CHANGELOG.md +7 -86
  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/color-picker-field.d.ts +13 -0
  9. package/dist/color-picker-field.d.ts.map +1 -0
  10. package/dist/color-picker-field.js +181 -0
  11. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  12. package/dist/dialogs/dynamic-record.js +99 -61
  13. package/dist/dynamic-columns.d.ts.map +1 -1
  14. package/dist/dynamic-columns.js +23 -8
  15. package/dist/dynamic-form.d.ts +1 -0
  16. package/dist/dynamic-form.d.ts.map +1 -1
  17. package/dist/dynamic-form.js +3 -1
  18. package/dist/dynamic-select-field.d.ts.map +1 -1
  19. package/dist/dynamic-select-field.js +6 -3
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/permissions-manager.d.ts +5 -0
  24. package/dist/permissions-manager.d.ts.map +1 -1
  25. package/dist/permissions-manager.js +67 -23
  26. package/dist/use-print-document.d.ts +10 -1
  27. package/dist/use-print-document.d.ts.map +1 -1
  28. package/dist/use-print-document.js +28 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/color-picker-field.test.tsx +18 -0
  31. package/src/__tests__/filename-from-disposition.test.ts +30 -0
  32. package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
  33. package/src/__tests__/record-detail-display.test.tsx +30 -0
  34. package/src/action-modal-dispatcher.tsx +110 -22
  35. package/src/addon-loader.tsx +7 -1
  36. package/src/color-picker-field.tsx +296 -0
  37. package/src/dialogs/dynamic-record.tsx +139 -65
  38. package/src/dynamic-columns.tsx +28 -12
  39. package/src/dynamic-form.tsx +9 -1
  40. package/src/dynamic-select-field.tsx +7 -3
  41. package/src/index.ts +2 -0
  42. package/src/permissions-manager.tsx +98 -39
  43. package/src/use-print-document.ts +38 -2
@@ -0,0 +1,296 @@
1
+ // ColorPickerField — modern popover color control (SV plane + hue + hex).
2
+ // Used by PermissionsManager role dialog and DynamicForm `type: "color"`.
3
+ // Value is always a #rrggbb string (empty → falls back to DEFAULT_HEX visually).
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import { Pipette } from 'lucide-react'
6
+ import { cn } from '@asteby/metacore-ui/lib'
7
+ import {
8
+ Button,
9
+ Input,
10
+ Popover,
11
+ PopoverContent,
12
+ PopoverTrigger,
13
+ } from '@asteby/metacore-ui/primitives'
14
+
15
+ export const DEFAULT_ROLE_COLOR = '#3b82f6'
16
+
17
+ export interface ColorPickerFieldProps {
18
+ value?: string
19
+ onChange: (hex: string) => void
20
+ /** Optional label for a11y on the trigger. */
21
+ 'aria-label'?: string
22
+ className?: string
23
+ disabled?: boolean
24
+ }
25
+
26
+ type HSV = { h: number; s: number; v: number }
27
+
28
+ function clamp(n: number, min: number, max: number) {
29
+ return Math.min(max, Math.max(min, n))
30
+ }
31
+
32
+ /** Normalize to #rrggbb or '' if invalid. */
33
+ export function normalizeHex(raw: unknown): string {
34
+ if (typeof raw !== 'string') return ''
35
+ let s = raw.trim()
36
+ if (!s) return ''
37
+ if (s[0] !== '#') s = `#${s}`
38
+ if (/^#[0-9a-fA-F]{3}$/.test(s)) {
39
+ const r = s[1],
40
+ g = s[2],
41
+ b = s[3]
42
+ s = `#${r}${r}${g}${g}${b}${b}`
43
+ }
44
+ if (!/^#[0-9a-fA-F]{6}$/.test(s)) return ''
45
+ return s.toLowerCase()
46
+ }
47
+
48
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
49
+ const n = normalizeHex(hex)
50
+ if (!n) return null
51
+ return {
52
+ r: parseInt(n.slice(1, 3), 16),
53
+ g: parseInt(n.slice(3, 5), 16),
54
+ b: parseInt(n.slice(5, 7), 16),
55
+ }
56
+ }
57
+
58
+ function rgbToHex(r: number, g: number, b: number): string {
59
+ const h = (n: number) =>
60
+ clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0')
61
+ return `#${h(r)}${h(g)}${h(b)}`
62
+ }
63
+
64
+ function rgbToHsv(r: number, g: number, b: number): HSV {
65
+ r /= 255
66
+ g /= 255
67
+ b /= 255
68
+ const max = Math.max(r, g, b)
69
+ const min = Math.min(r, g, b)
70
+ const d = max - min
71
+ let h = 0
72
+ if (d !== 0) {
73
+ if (max === r) h = ((g - b) / d) % 6
74
+ else if (max === g) h = (b - r) / d + 2
75
+ else h = (r - g) / d + 4
76
+ h *= 60
77
+ if (h < 0) h += 360
78
+ }
79
+ const s = max === 0 ? 0 : d / max
80
+ return { h, s, v: max }
81
+ }
82
+
83
+ function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
84
+ const c = v * s
85
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
86
+ const m = v - c
87
+ let rp = 0,
88
+ gp = 0,
89
+ bp = 0
90
+ if (h < 60) [rp, gp, bp] = [c, x, 0]
91
+ else if (h < 120) [rp, gp, bp] = [x, c, 0]
92
+ else if (h < 180) [rp, gp, bp] = [0, c, x]
93
+ else if (h < 240) [rp, gp, bp] = [0, x, c]
94
+ else if (h < 300) [rp, gp, bp] = [x, 0, c]
95
+ else [rp, gp, bp] = [c, 0, x]
96
+ return {
97
+ r: (rp + m) * 255,
98
+ g: (gp + m) * 255,
99
+ b: (bp + m) * 255,
100
+ }
101
+ }
102
+
103
+ function hexToHsv(hex: string): HSV {
104
+ const rgb = hexToRgb(hex) || hexToRgb(DEFAULT_ROLE_COLOR)!
105
+ return rgbToHsv(rgb.r, rgb.g, rgb.b)
106
+ }
107
+
108
+ function hsvToHex(hsv: HSV): string {
109
+ const { r, g, b } = hsvToRgb(hsv.h, hsv.s, hsv.v)
110
+ return rgbToHex(r, g, b)
111
+ }
112
+
113
+ function hueCss(h: number): string {
114
+ const { r, g, b } = hsvToRgb(h, 1, 1)
115
+ return rgbToHex(r, g, b)
116
+ }
117
+
118
+ export function ColorPickerField({
119
+ value,
120
+ onChange,
121
+ 'aria-label': ariaLabel = 'Color',
122
+ className,
123
+ disabled,
124
+ }: ColorPickerFieldProps) {
125
+ const hex = normalizeHex(value) || DEFAULT_ROLE_COLOR
126
+ const [open, setOpen] = useState(false)
127
+ const [hsv, setHsv] = useState<HSV>(() => hexToHsv(hex))
128
+ const [hexDraft, setHexDraft] = useState(hex)
129
+ const svRef = useRef<HTMLDivElement>(null)
130
+ const dragging = useRef(false)
131
+
132
+ // Sync from external value when popover closed (or first open).
133
+ useEffect(() => {
134
+ if (dragging.current) return
135
+ const next = normalizeHex(value) || DEFAULT_ROLE_COLOR
136
+ setHsv(hexToHsv(next))
137
+ setHexDraft(next)
138
+ }, [value, open])
139
+
140
+ const commit = useCallback(
141
+ (next: HSV) => {
142
+ setHsv(next)
143
+ const out = hsvToHex(next)
144
+ setHexDraft(out)
145
+ onChange(out)
146
+ },
147
+ [onChange],
148
+ )
149
+
150
+ const setFromPointer = useCallback(
151
+ (clientX: number, clientY: number) => {
152
+ const el = svRef.current
153
+ if (!el) return
154
+ const rect = el.getBoundingClientRect()
155
+ const s = clamp((clientX - rect.left) / rect.width, 0, 1)
156
+ const v = clamp(1 - (clientY - rect.top) / rect.height, 0, 1)
157
+ commit({ ...hsv, s, v })
158
+ },
159
+ [commit, hsv],
160
+ )
161
+
162
+ useEffect(() => {
163
+ if (!open) return
164
+ const onMove = (e: PointerEvent) => {
165
+ if (!dragging.current) return
166
+ setFromPointer(e.clientX, e.clientY)
167
+ }
168
+ const onUp = () => {
169
+ dragging.current = false
170
+ }
171
+ window.addEventListener('pointermove', onMove)
172
+ window.addEventListener('pointerup', onUp)
173
+ return () => {
174
+ window.removeEventListener('pointermove', onMove)
175
+ window.removeEventListener('pointerup', onUp)
176
+ }
177
+ }, [open, setFromPointer])
178
+
179
+ const svStyle = useMemo(
180
+ () => ({
181
+ background: `
182
+ linear-gradient(to top, #000, transparent),
183
+ linear-gradient(to right, #fff, ${hueCss(hsv.h)})
184
+ `,
185
+ }),
186
+ [hsv.h],
187
+ )
188
+
189
+ const applyHexDraft = () => {
190
+ const n = normalizeHex(hexDraft)
191
+ if (!n) {
192
+ setHexDraft(hsvToHex(hsv))
193
+ return
194
+ }
195
+ setHsv(hexToHsv(n))
196
+ setHexDraft(n)
197
+ onChange(n)
198
+ }
199
+
200
+ return (
201
+ <Popover open={open} onOpenChange={setOpen}>
202
+ <PopoverTrigger asChild>
203
+ <Button
204
+ type="button"
205
+ variant="outline"
206
+ disabled={disabled}
207
+ aria-label={ariaLabel}
208
+ className={cn(
209
+ 'h-10 w-full justify-start gap-3 px-2.5 font-normal',
210
+ className,
211
+ )}
212
+ >
213
+ <span
214
+ className="h-6 w-6 shrink-0 rounded-md border border-black/10 shadow-sm ring-1 ring-black/5 dark:border-white/10"
215
+ style={{ background: hex }}
216
+ aria-hidden
217
+ />
218
+ <span className="font-mono text-sm uppercase tracking-wide text-foreground">
219
+ {hex}
220
+ </span>
221
+ <Pipette className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
222
+ </Button>
223
+ </PopoverTrigger>
224
+ <PopoverContent className="w-[260px] p-3" align="start">
225
+ <div className="flex flex-col gap-3">
226
+ <div
227
+ ref={svRef}
228
+ role="slider"
229
+ aria-label="Saturación y brillo"
230
+ tabIndex={0}
231
+ className="relative h-36 w-full cursor-crosshair touch-none overflow-hidden rounded-lg border border-border/60"
232
+ style={svStyle}
233
+ onPointerDown={(e) => {
234
+ dragging.current = true
235
+ ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
236
+ setFromPointer(e.clientX, e.clientY)
237
+ }}
238
+ >
239
+ <span
240
+ className="pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md ring-1 ring-black/30"
241
+ style={{
242
+ left: `${hsv.s * 100}%`,
243
+ top: `${(1 - hsv.v) * 100}%`,
244
+ background: hsvToHex(hsv),
245
+ }}
246
+ />
247
+ </div>
248
+
249
+ <div className="flex flex-col gap-1.5">
250
+ <label className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
251
+ Tono
252
+ </label>
253
+ <input
254
+ type="range"
255
+ min={0}
256
+ max={360}
257
+ step={1}
258
+ value={Math.round(hsv.h)}
259
+ aria-label="Tono"
260
+ onChange={(e) =>
261
+ commit({ ...hsv, h: Number(e.target.value) })
262
+ }
263
+ className="h-3 w-full cursor-pointer appearance-none rounded-full"
264
+ style={{
265
+ background:
266
+ 'linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)',
267
+ }}
268
+ />
269
+ </div>
270
+
271
+ <div className="flex items-center gap-2">
272
+ <span
273
+ className="h-9 w-9 shrink-0 rounded-md border border-black/10 shadow-inner ring-1 ring-black/5"
274
+ style={{ background: hsvToHex(hsv) }}
275
+ aria-hidden
276
+ />
277
+ <Input
278
+ value={hexDraft}
279
+ spellCheck={false}
280
+ aria-label="Hexadecimal"
281
+ className="h-9 font-mono uppercase"
282
+ onChange={(e) => setHexDraft(e.target.value)}
283
+ onBlur={applyHexDraft}
284
+ onKeyDown={(e) => {
285
+ if (e.key === 'Enter') {
286
+ e.preventDefault()
287
+ applyHexDraft()
288
+ }
289
+ }}
290
+ />
291
+ </div>
292
+ </div>
293
+ </PopoverContent>
294
+ </Popover>
295
+ )
296
+ }
@@ -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