@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.
- package/CHANGELOG.md +5 -84
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +102 -23
- package/dist/addon-loader.d.ts +1 -1
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +8 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +107 -52
- package/dist/display-value.d.ts +23 -0
- package/dist/display-value.d.ts.map +1 -1
- package/dist/display-value.js +34 -1
- package/dist/dynamic-columns.d.ts +3 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +51 -6
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +8 -9
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +10 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/metadata-cache.d.ts.map +1 -1
- package/dist/metadata-cache.js +8 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-debounced-value.d.ts +8 -0
- package/dist/use-debounced-value.d.ts.map +1 -0
- package/dist/use-debounced-value.js +15 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +6 -4
- package/dist/use-print-document.d.ts +10 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +28 -1
- package/package.json +1 -1
- package/src/__tests__/filename-from-disposition.test.ts +30 -0
- package/src/__tests__/image-stack.test.tsx +52 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
- package/src/__tests__/record-detail-display.test.tsx +30 -0
- package/src/__tests__/use-debounced-value.test.ts +56 -0
- package/src/action-modal-dispatcher.tsx +110 -22
- package/src/addon-loader.tsx +7 -1
- package/src/dialogs/dynamic-record.tsx +167 -51
- package/src/display-value.tsx +82 -4
- package/src/dynamic-columns.tsx +94 -5
- package/src/dynamic-select-field.tsx +9 -9
- package/src/dynamic-table.tsx +9 -6
- package/src/index.ts +7 -0
- package/src/metadata-cache.ts +8 -1
- package/src/types.ts +2 -0
- package/src/use-debounced-value.ts +17 -0
- package/src/use-dynamic-filters.ts +6 -4
- 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;
|
|
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' &&
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/addon-loader.tsx
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
794
|
-
for (const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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,
|
|
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
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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=
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1372
|
-
// label.
|
|
1373
|
-
//
|
|
1374
|
-
if (
|
|
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
|
-
|
|
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
|