// DynamicRecordDialog — renders a create/edit/view modal for a model based // on metadata fetched from `/metadata/modal/:model`. This is the single, // SDK-owned source of truth for declarative record rendering (the ops fork was // consolidated back into here): tz-aware dates, FK image/label leads in both // view and edit, resolved relation/user-object labels (never raw JSON), nil-UUID // elision, pro option color/icon badges, and one_to_many child panels. // // Host-owned infra that was referenced by alias (axios client, branch store) // flows through from runtime-react. Host-specific runtime values — // the image-url resolver and the org IANA timezone — are passed as props so the // SDK stays transport- and host-agnostic. import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ModelSchema } from './types' /** Model key of the open create/edit dialog — used to hide "+" on self-FK pickers * (e.g. Customer.parent_id) so they don't nest another "Crear Cliente" modal. */ const RecordDialogModelContext = createContext(undefined) import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, Textarea, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Skeleton, Badge, Popover, PopoverContent, PopoverTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Calendar, } from '@asteby/metacore-ui/primitives' import { cn } from '@asteby/metacore-ui/lib' import { toast } from 'sonner' import { format, parseISO } from 'date-fns' import { es } from 'date-fns/locale' import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react' import { BarcodeScanner } from '../barcode-scanner' import { useApi } from '../api-context' import { useBranchCreateGate } from '../branch-create-gate' import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error' import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field' import { DynamicMultiSelectField } from '../dynamic-multi-select-field' import { DynamicRelations } from '../dynamic-relations' import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver' import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-form-schema' import type { VisibleWhen } from '../types' import { groupFieldsBySection, type FormLayout } from '../form-layout' import { FieldSection, WizardProgress } from '../form-layout-ui' import { AssistInterview } from '../assist-interview' import { FieldCell } from '../field-grid' import { isNilUuid, normalizeNilUuid } from '../nil-uuid' import { normalizeRefFieldsForSubmit } from './normalize-submit' import { validateValues, bagHasErrors } from '../validator' import { DynamicIcon, isLucideIconName } from '../dynamic-icon' import { IconPickerField } from '../icon-picker-field' import { humanizeToken } from '../dynamic-columns-helpers' import { formatDateCell } from '../dynamic-columns' import { ImageStack, OptionBadge, statusColorFor, useIsDarkTheme, type DisplayOption, } from '../display-value' import { MediaValue, RichText } from '../rich-url' import { generateBadgeStyles } from '@asteby/metacore-ui/lib' import { CollectionCell, type ItemField } from '../collection-cell' import type { ActionFieldDef, RelationMeta } from '../types' import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context' import { TimeZoneContext, CurrencyContext } from '../org-runtime-context' // Re-export the resolver type so `index.ts`'s // `export type { … GetImageUrl } from './dialogs/dynamic-record'` keeps working. export type { GetImageUrl } export interface FieldOption { value: string label: string /** * Pro option metadata the backend serves for enum/option fields (e.g. * `product_type`) so the view renders a colored/iconed badge instead of the * raw value ("storable" → "Almacenable"). All optional and driven entirely * by the served metadata — plain options stay plain. */ color?: string icon?: string image?: string } export interface FieldDef { key: string label: string type: 'text' | 'textarea' | 'select' | 'search' | 'number' | 'date' | 'email' | 'url' | 'boolean' | 'image' | string required?: boolean options?: FieldOption[] defaultValue?: any placeholder?: string readonly?: boolean hidden?: boolean searchEndpoint?: string filterBy?: string /** * FK target model the kernel auto-derives for a belongs_to column (>= * v0.46.x serves it on modal fields, not just action fields). When present * the native form renders an async searchable picker (`DynamicSelectField`) * against `/api/options/?field=id` — with option thumbnails when the * remote rows carry an `image` — instead of a raw FK text input. View mode * shows the resolved thumbnail + label. Tolerates the snake_case * `source`/`relation` aliases the manifest may serve. */ ref?: string source?: string relation?: string /** * Explicit renderer hint. Wins over the `type` switch: `dynamic_select` * forces the searchable picker, `upload` forces the file dropzone. Lets the * kernel opt a plain text/uuid column into a rich widget without changing * its SQL type. Unknown values fall through to the `type`-based default. */ widget?: string /** * Declarative display hint the backend stamps on the column/modal field * (mirrors the table column's `cellStyle`). `'currency'` makes the view * renderer format the numeric value in the org currency. Optional — * absent, a money-key heuristic still detects obvious money fields. */ cellStyle?: string /** * Per-field style overrides served alongside `cellStyle` (e.g. * `{ currency: 'MXN' }`). When it carries an explicit `currency` it wins * over the org fallback. */ styleConfig?: Record /** * Declared schema for a jsonb line-items field (kernel v3 `item_fields`). * The backend serves this on modal/detail fields the same way it does on * table columns. When present the read-only detail view renders the * `CollectionCell` mini-table with these (already-localized) headers in * order and resolves `ref` columns to the backend-injected sibling label. * Tolerates the snake_case `item_fields` the kernel serves. */ itemFields?: ItemField[] /** snake_case alias served by the kernel for `itemFields`. */ item_fields?: ItemField[] /** * Conditional visibility: render — and required-check — this field only * while a sibling field's current value matches the predicate. Mirrors the * kernel v3 `visible_when` (projected onto the served modal FieldDef). * Tolerates the camelCase alias. Absent = always visible; a hidden field is * dropped from the required-gate so it never blocks submit. Evaluated by * `evaluateVisibleWhen` against the live form values. */ visible_when?: VisibleWhen /** camelCase alias for `visible_when`. */ visibleWhen?: VisibleWhen /** * Form-layout membership: the key of the `form_layout` section this field * belongs to (kernel PR #230). Absent → the default group. See * `groupFieldsBySection`. */ section?: string } // Permissive shape: the wire payload may omit some fields (e.g. `title` is // optional on legacy backends). Keep field types loose so a host-supplied // `ModelSchema` (see ./types.ts) is structurally assignable here. interface ModalMetadata { title?: string /** * i18n key for the model name (e.g. "accounting.model.account"). The backend * can't always localize it (the addon bundle is only registered at install * time), so it ships the key and we translate here — the frontend loads each * addon's i18n live from the hub, so it resolves without a reinstall. */ titleKey?: string createTitle?: string editTitle?: string fields?: FieldDef[] /** * Declarative form layout (kernel PR #230): groups the fields into named * sections rendered stacked (`mode:"sections"`) or as a wizard * (`mode:"steps"`). Absent → the legacy flat two-column grid. Tolerates the * camelCase alias an app might author. */ form_layout?: FormLayout /** camelCase alias for `form_layout`. */ formLayout?: FormLayout /** * Backend-localized CRUD success messages (modal metadata). Preferred over * the raw response message which is not localized. */ messages?: { created?: string; updated?: string; deleted?: string } } type TFn = (key: string) => string // localizedModelName resolves the (possibly addon-i18n) model name: prefer the // translated titleKey, fall back to the backend-provided raw title. function localizedModelName(meta: ModalMetadata, t: TFn): string { if (meta.titleKey && t(meta.titleKey) !== meta.titleKey) return t(meta.titleKey) return meta.title || '' } export interface DynamicRecordDialogProps { open: boolean onOpenChange: (open: boolean) => void /** * Set by the host's inline-create bridge on the sibling "Crear" dialog: * marks this dialog as the nested-create SELF so the ui Dialog stamps * data-nested-inline-create (surgical focus release) and the body guard * lets it close while the depth lock is held. */ nestedInlineCreateSelf?: boolean /** * Fields merged into the modal schema after load (by key). Existing keys are * shallow-merged; missing keys are prepended. Hosts use this to inject * required scope fields (e.g. branch_id) omitted from compiled DefineModal. */ ensureFields?: FieldDef[] mode: 'view' | 'edit' | 'create' model: string recordId?: string | null endpoint?: string /** Fired after a successful save; receives the persisted record (when the * backend returns it) so callers — e.g. the inline-create bridge behind a * dynamic_select "+" — can auto-select the new row. */ onSaved?: (record?: any) => void /** * Optional override invoked instead of the default `POST` when the dialog * is in `create` mode. Hosts may use this to route writes through custom * mutations (optimistic updates, audit hooks, etc.). The dialog still * closes and fires `onSaved` on success. */ onCreate?: (data: Record) => Promise<{ id?: string | number } | void> /** * Optional override invoked instead of the default `PUT` when the dialog * is in `edit` mode. Receives the record id and the form payload. */ onUpdate?: (recordId: string, data: Record) => Promise<{ id?: string | number } | void> /** * Optional default values seeded into the form on `create`. Ignored when * `mode` is `'edit'` or `'view'` (those fetch from the record endpoint). */ defaults?: Record /** * Field keys that render locked (visible, disabled, seeded from * `defaults`) on create instead of editable. Ignored outside create mode. * See `CreateRecordDialogProps.lockedFields` for the rationale. */ lockedFields?: string[] /** * Optional pre-fetched metadata. When provided the dialog skips the * `/metadata/modal/:model` request and uses this shape directly. */ schema?: ModelSchema /** * Optional handler shown as a "Delete" action in `view` mode. The dialog * awaits the promise and closes on success. Omit to hide the action. */ onDelete?: () => Promise /** * Optional handler shown as an "Edit" action in `view` mode. Omit to hide * the action. */ onEdit?: () => void /** * Deliberate escape hatch: open the full `/m/:model/:id` detail page (with * cross-module related records) for records too heavy for the modal. * Rendered as a footer link in view mode when provided. */ onOpenFullPage?: () => void /** * The row object the table already loaded. When provided, the dialog renders * instantly from it (no spinner) and reuses the table's pro siblings — the * resolved relation (`row.category = {value,label}`), served option lists and * image urls. A background fetch only fills in fields the list row omitted. */ initialRecord?: Record | null /** * Host resolver turning a (possibly relative) storage path into a fetchable * URL for images/avatars/thumbnails. Defaults to identity. Pass the host's * `getImageUrl` so addon-served relative paths render. */ getImageUrl?: GetImageUrl /** * Org IANA timezone (e.g. `America/Mexico_City`). Threaded into the tz-aware * `formatDateCell` so datetime/timestamp instants render in the org zone * regardless of the viewer's browser timezone. Pure `date` values pin to UTC. */ timeZone?: string /** * Org ISO-4217 currency code (e.g. `MXN`) used as the fallback for money * fields (`cellStyle:'currency'` or the money-key heuristic) that lack an * explicit per-field currency. Optional — defaults to 'USD'. */ currency?: string /** * Fired after a child relation row (line item, etc.) is created/updated/ * deleted from within the dialog. The dialog ALREADY refetches its own * parent record so server-recomputed rollups (sub_total, tax_amount, total) * appear in place — this callback additionally lets the host invalidate its * own list/detail query so the parent row's totals refresh underneath. */ onChange?: () => void } function resolvePath(obj: any, path: string): any { return path.split('.').reduce((acc, part) => acc?.[part], obj) } // objectLabel pulls a human label off a resolved relation/user object the // backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such // as created_by), or `{title}`. Returns undefined for plain/empty objects. export function objectLabel(value: any): string | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined const label = value.label ?? value.name ?? value.title if (label != null && label !== '') return String(label) return undefined } // pickImage reads an image-ish path off a resolved object (FK sibling, user). function pickImage(value: any): string | undefined { if (!value || typeof value !== 'object') return undefined const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail return typeof img === 'string' && img !== '' ? img : undefined } // relationSiblingValue reads the resolved relation the table served alongside an // FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling // `record.category = {value,label,image?}` (or a bare string/{name}); returns // the raw sibling (object or string) so the caller can extract label + image. export function relationSiblingValue(field: FieldDef, record: any): any { if (!record) return undefined const candidates: string[] = [] const ref = getFieldRef(field as ActionFieldDef) if (ref) candidates.push(ref) if (typeof field.key === 'string' && field.key.endsWith('_id')) candidates.push(field.key.slice(0, -3)) for (const key of candidates) { const sib = record[key] if (sib === undefined || sib === null) continue if (typeof sib === 'string') { if (sib === '' || isNilUuid(sib)) continue return sib } if (typeof sib === 'object') return sib } return undefined } // fieldItemFields reads the declared jsonb line-items schema off a field, // tolerating the snake_case `item_fields` alias the kernel serves. export function fieldItemFields(field: FieldDef): ItemField[] | undefined { return field.itemFields ?? field.item_fields } // isLineItemsField — a jsonb line-items column (e.g. Transfer.items): it either // declares an `item_fields` schema, or its value is a structured array/object. // These are action-built documents; field-by-field editing of the array is out // of scope, so the edit dialog renders them read-only (the inline table) rather // than an input that would stringify to "[object Object]". Scalars and known // editable widgets (media/upload, color, dates) are NOT line-items. export function isLineItemsField(field: FieldDef, value: any): boolean { if (field.type === 'image' || field.widget === 'upload') return false // A `ref` field declared `multiple: true` (DynamicMultiSelectField) is a // plain array of target ids, not a structured line-items document — it // has its own editable picker and must not fall into the read-only // inline-table branch just because its current value is an array // (including the [] default on a freshly-created record). if ((field as ActionFieldDef).multiple) return false if (fieldItemFields(field)?.length) return true if (Array.isArray(value)) return true return ( value !== null && typeof value === 'object' && !(value instanceof Date) ) } // fkSeedOption builds the pre-resolved option for a FK select's CURRENT value // from the relation sibling the backend injected alongside the column // (`source_warehouse_id` → `source_warehouse = {value,label,image?}`, the key // without `_id`; same convention as the jsonb refs). Lets the edit picker show // the related record's NAME instead of the raw uuid without waiting for a // network lookup. Returns null when there is no usable sibling (the picker then // falls back to its existing typeahead/lookup behaviour). export function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null { if (value === undefined || value === null || value === '') return null const sib = relationSiblingValue(field, record) const label = typeof sib === 'string' ? sib : objectLabel(sib) if (!label) return null const id = String(value) return { id, value: id, label, name: label, image: pickImage(sib) ?? null, } } // servedOption matches a field's served option list (enum/select with // {value,label,color,icon,image}) against the current value. function servedOption(field: FieldDef, value: any): FieldOption | undefined { if (!field.options?.length) return undefined return field.options.find(o => o.value === String(value ?? '')) } // createdBySibling reads the resolver object the backend serves for the // auto-injected `created_by` avatar column: {name, avatar, email}. function createdBySibling(value: any, record: any): { name?: string; avatar?: string; email?: string } | undefined { const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email)) return obj return undefined } // isRelationField — a field that resolves to another row (so view renders a lead // + label and edit renders the searchable picker). function isRelationField(field: FieldDef): boolean { return ( field.type === 'search' || field.type === 'dynamic_select' || field.widget === 'dynamic_select' || !!getFieldRef(field as ActionFieldDef) || !!field.searchEndpoint ) } // looksLikeForeignKey — true only for real FKs. A bare `*_id` suffix is NOT // enough: columns like `external_id`, `trace_id`, or `invoice_uid` are plain // text identifiers from a PAC/provider, not belongs_to relations. Treating them // as relations rendered an InitialsAvatar ("6" chip next to "6a8c…") and made // fiscal detail modals look broken. function looksLikeForeignKey(field: FieldDef): boolean { if (isRelationField(field)) return true if (typeof field.key !== 'string' || !field.key.endsWith('_id')) return false const t = String(field.type || '').toLowerCase() return ( t === 'uuid' || t === 'search' || t === 'relation' || t === 'dynamic_select' || t === 'belongs_to' ) } function formatDisplayValue(rawValue: any, field: FieldDef): string { // Unset nullable FK serialized as the nil UUID renders as empty, not zeros. const value = normalizeNilUuid(rawValue) if (value === null || value === undefined || value === '') return '—' const objLabel = objectLabel(value) if (objLabel !== undefined) return objLabel if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No' if (field.type === 'select' && field.options?.length) { const match = field.options.find(o => o.value === String(value)) // Matched option label wins (localized); humanize the raw token only // when no declared option matches the value. return match?.label ?? humanizeToken(value) } // Structured value with no label — JSON beats "[object Object]". if (typeof value === 'object') return JSON.stringify(value) return String(value) } const MODE_CONFIG = { create: { getTitle: (meta: ModalMetadata, t: TFn) => { const name = localizedModelName(meta, t) return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro') }, description: 'Completa los campos para crear un nuevo registro.', submitLabel: 'Crear', submittingLabel: 'Creando...', cancelLabel: 'Cancelar', }, edit: { getTitle: (meta: ModalMetadata, t: TFn) => { const name = localizedModelName(meta, t) return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro') }, description: 'Modifica los campos y guarda los cambios.', submitLabel: 'Guardar cambios', submittingLabel: 'Guardando...', cancelLabel: 'Cancelar', }, view: { getTitle: (meta: ModalMetadata, t: TFn) => localizedModelName(meta, t) || meta.title || 'Ver registro', description: 'Información detallada del registro.', submitLabel: '', submittingLabel: '', cancelLabel: 'Cerrar', }, } // Context threading host runtime values to nested field components (uploads, // image leads, tz-aware dates) without prop-drilling through every renderer. const ModelContext = createContext('') // Money-key heuristic mirroring the backend's `inferDisplayCellStyle`: lets the // dialog format obvious money fields as currency even when the backend hasn't // stamped `cellStyle:'currency'` yet. Case-insensitive; matches a key that // equals one of these, or ends with `_`, or starts with `_`. const MONEY_KEY_HEURISTIC = ['price', 'amount', 'total', 'cost', 'subtotal', 'balance', 'paid'] // isMoneyField decides whether a field should render as currency. The explicit // `cellStyle:'currency'` stamp always wins; otherwise a numeric value whose key // matches the money heuristic qualifies (robustness fallback). export function isMoneyField(field: FieldDef, value: any): boolean { if (field.cellStyle === 'currency') return true if (value === null || value === undefined || value === '') return false const num = typeof value === 'number' ? value : Number(value) if (isNaN(num)) return false const key = String(field.key || '').toLowerCase() if (!key) return false return MONEY_KEY_HEURISTIC.some( m => key === m || key.endsWith(`_${m}`) || key.startsWith(`${m}_`), ) } // filterVisibleFields decides which declared fields render in the form for a // given mode. `hidden` fields never render. A `readonly` (server/system- // generated) field is EXCLUDED on create — the user can't set a value the // server will overwrite — but stays visible on edit/view (rendered disabled). // // `formValues`, when provided, additionally applies each field's `visible_when` // predicate against the live form values (conditional visibility): a field is // dropped while the referenced sibling's value does not match. Driving both the // render and the required-gate off this same list means a hidden field never // blocks submit. Omitting `formValues` keeps the legacy (always-visible) // behaviour for callers that only gate on mode. export function filterVisibleFields( fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create', formValues?: Record, ): FieldDef[] { return (fields ?? []).filter(f => { if (f.hidden) return false if (mode === 'create' && f.readonly) return false if (formValues && !evaluateVisibleWhen(getVisibleWhen(f), formValues)) return false return true }) } // stripHiddenFieldValues drops from a flat form-values object the keys of any // declared field currently hidden by its `visible_when` predicate, so the // submit never POSTs a value the form isn't showing (e.g. a DiscountRule with // rule_scope=category must not send the product_id / customer_id it hides). // This mirrors dynamic-form.tsx, which builds its Zod schema only over the // visible fields — hidden fields are dropped from BOTH the render/required-gate // AND the submitted values. Keys with no matching declared field, or whose // field carries no `visible_when`, always pass through (retrocompat). export function stripHiddenFieldValues( values: Record, fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create', ): Record { const visibleKeys = new Set(filterVisibleFields(fields, mode, values).map(f => f.key)) const out: Record = {} for (const [key, value] of Object.entries(values)) { const field = (fields ?? []).find(f => f.key === key) if (field && !visibleKeys.has(key) && getVisibleWhen(field)) continue out[key] = value } return out } function applyEnsureFields(meta: ModalMetadata | null | undefined, ensureFields?: FieldDef[]): ModalMetadata | null { if (!meta) return meta ?? null if (!ensureFields?.length) return meta const fields = Array.isArray(meta.fields) ? [...meta.fields] : [] for (const ensure of ensureFields) { const idx = fields.findIndex((f) => f?.key === ensure.key) if (idx >= 0) { fields[idx] = { ...fields[idx], ...ensure } } else { fields.unshift(ensure) } } return { ...meta, fields } } export function DynamicRecordDialog({ open, onOpenChange, nestedInlineCreateSelf, ensureFields, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, lockedFields, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, currency, onChange, }: DynamicRecordDialogProps) { const api = useApi() const branchGate = useBranchCreateGate() const { t } = useTranslation() const [modalMeta, setModalMeta] = useState( schema ? (schema as ModalMetadata) : null, ) const [relations, setRelations] = useState([]) const [record, setRecord] = useState(null) const [formValues, setFormValues] = useState>({}) // Per-field validation errors (localized strings), keyed by field.key. Shown // inline under each input; populated from a 422 `errors` map or the client // required-field check, cleared per-field on change and wholesale on reopen. const [fieldErrors, setFieldErrors] = useState>({}) // Unique form id per dialog instance — nested create must not share // id={formId} or the child footer submits the parent. const formId = useId() const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [deleting, setDeleting] = useState(false) // Wizard step cursor (form_layout mode:"steps" only). const [stepIndex, setStepIndex] = useState(0) const isCreate = mode === 'create' const isView = mode === 'view' const isEditable = mode === 'create' || mode === 'edit' const config = MODE_CONFIG[mode] // ── Fetch metadata + record when dialog opens ────────────────────────── useEffect(() => { if (!open) return if (!isCreate && !recordId) return // Fresh open → drop any validation errors from a prior submit and reset // the wizard to its first step. setFieldErrors({}) setStepIndex(0) let cancelled = false // Seed instantly from the row the table already has so view/edit render // without a spinner. The list row carries the pro siblings (resolved // relation, served options, image url) the table cells used. const seed = !isCreate && initialRecord ? initialRecord : null if (seed) setRecord(seed) const seedForm = (meta: ModalMetadata, rec: any) => { const initial: Record = {} for (const field of meta.fields ?? []) { initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? '' } setFormValues(initial) } // A field value is "missing" from the seed row when the list omitted that // column. Sibling pro fields aren't form fields, so we only check the // declared field keys. const seedIsComplete = (meta: ModalMetadata, rec: any) => (meta.fields ?? []).every(f => { if (f.hidden) return true const v = resolvePath(rec, f.key) return v !== undefined }) const load = async () => { // Always skeleton until modal metadata is ready to paint fields. // A list-row `initialRecord` seed is NOT enough on its own: without // meta we don't know which fields to render, which left the dialog // body blank (only the "Información detallada…" description) until // `/metadata/modal` resolved — especially visible on slow/cold loads. setLoading(true) try { let meta: ModalMetadata | null = schema ? (schema as ModalMetadata) : null if (!meta) { const metaRes = await api.get(`/metadata/modal/${model}`) if (cancelled) return meta = metaRes.data?.data ?? metaRes.data } meta = applyEnsureFields(meta, ensureFields) setModalMeta(meta) if (isCreate) { const initial: Record = {} for (const field of meta?.fields ?? []) { initial[field.key] = (defaults && Object.prototype.hasOwnProperty.call(defaults, field.key) ? defaults[field.key] : field.defaultValue) ?? '' } setFormValues(initial) return } // Render immediately from the seed row. if (seed && meta) seedForm(meta, seed) // Only hit the record endpoint if the seed is absent or missing // some declared field — keeps the modal instant for full rows. if (!seed || (meta && !seedIsComplete(meta, seed))) { const recordEndpoint = endpoint ? `${endpoint}/${recordId}` : `/dynamic/${model}/${recordId}` const recRes = await api.get(recordEndpoint) if (cancelled) return const rec = recRes.data?.data ?? recRes.data // Merge so the fetched record fills gaps without dropping the // table's pro siblings (the detail endpoint may omit them). const merged = seed ? { ...seed, ...rec } : rec setRecord(merged) if (meta) seedForm(meta, merged) } } catch (err) { console.error('[DynamicRecordDialog] load error:', err) if (!seed) toast.error(t('dynamic.load_error', { defaultValue: 'No se pudieron cargar los datos' })) } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } // initialRecord intentionally omitted: the row identity is captured per open // via recordId; re-seeding mid-open would clobber edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, recordId, model, endpoint, isCreate, schema, ensureFields]) // Reset when closed useEffect(() => { if (!open) { setModalMeta(null) setRelations([]) setRecord(null) setFormValues({}) } }, [open]) // Fetch the model's declared one_to_many/many_to_many edges so view AND edit // show child records (e.g. a sales order's line items) below the scalar // fields. The modal form is driven by MODAL metadata (fields); relations live // on TABLE metadata, hence the separate fetch. Skipped on create (no parent // record yet). View renders them read-only; edit lets the user add/edit/delete. useEffect(() => { if (!open || mode === 'create' || !recordId) { setRelations([]) return } let cancelled = false api.get(`/metadata/table/${model}`) .then(res => { if (cancelled) return const meta = res.data?.data ?? res.data const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : [] // Localize each panel header: the backend serves `label` as an // i18n key (addon bundle, loaded live) and the SDK renders it verbatim. setRelations( rels.map(rel => ({ ...rel, label: rel.label && t(rel.label) !== rel.label ? t(rel.label) : rel.label || rel.name, })), ) }) .catch(() => { if (!cancelled) setRelations([]) }) return () => { cancelled = true } }, [open, mode, model, recordId, api, t]) // After a child relation mutation (add/edit/delete line item) the server // recomputes the parent's declarative rollups (sub_total, tax_amount, total). // Refetch the parent record so those fresh totals render in place — view mode // reads `record`; edit mode also reseeds the form so derived fields update. // Then bubble onChange so the host can refresh its own list/detail query. const handleChildChange = useCallback(async () => { if (!isCreate && recordId) { try { const recordEndpoint = endpoint ? `${endpoint}/${recordId}` : `/dynamic/${model}/${recordId}` const recRes = await api.get(recordEndpoint) const rec = recRes.data?.data ?? recRes.data if (rec) { setRecord((prev: any) => (prev ? { ...prev, ...rec } : rec)) setFormValues(prev => { const next = { ...prev } for (const field of modalMeta?.fields ?? []) { const v = resolvePath(rec, field.key) if (v !== undefined) next[field.key] = v } return next }) } } catch (err) { console.error('[DynamicRecordDialog] parent refetch error:', err) } } onChange?.() }, [api, endpoint, model, recordId, isCreate, modalMeta, onChange]) // The human label for a field key, for localizing validation errors. Falls // back to a humanized key when the field is unknown (e.g. a server-side key // with no matching form field). const labelForKey = (key: string): string => { const f = (modalMeta?.fields ?? []).find(x => x.key === key) if (f?.label) return f.label return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } // Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into // inline field errors + a summary toast. When there is no field map, fall // back to the existing single cause-carrying toast. const handleSubmitError = (err: unknown) => { const map = extractFieldErrors(err) if (map) { const next: Record = {} for (const [key, issues] of Object.entries(map)) { next[key] = localizeFieldIssue(issues[0], labelForKey(key), t) } setFieldErrors(next) const visibleKeys = new Set( filterVisibleFields(modalMeta?.fields ?? [], mode, formValues).map(f => f.key), ) const orphans = Object.entries(next).filter(([k]) => !visibleKeys.has(k)) const description = orphans.length ? orphans.map(([k, msg]) => `${labelForKey(k)}: ${msg}`).join(' · ') : undefined toast.error( t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), description ? { description } : undefined, ) return } toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) }) } const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault() if (!modalMeta) return if (isEditable) { // Laravel-style: collect every issue from the shared validator // (required + rule strings / min/max / email…) on visible fields only. const visible = filterVisibleFields(modalMeta.fields, mode, formValues) const bag = validateValues(visible as ActionFieldDef[], formValues) if (bagHasErrors(bag)) { const labels: Record = {} for (const f of visible) labels[f.key] = f.label const next = localizeFieldErrorMap(bag, t, { labels }) setFieldErrors(next) const description = Object.entries(next) .map(([k, msg]) => { const label = labels[k] || labelForKey(k) return msg.toLowerCase().startsWith(String(label).toLowerCase()) ? msg : `${label}: ${msg}` }) .join(' · ') toast.error( t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }), description ? { description } : undefined, ) return } } // Required check passed → clear any prior validation errors. setFieldErrors({}) // Fields hidden by their `visible_when` predicate must not be POSTed: // the render, the required-gate and the payload all drive off the same // filter, so a DiscountRule with scope=category never submits the // product_id / customer_id it isn't showing. Mirrors dynamic-form.tsx, // which builds its Zod only over visibleFields. const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode) // Empty reference pickers → null (not "" / nil-UUID) so nullable FK // columns accept them instead of raising a 23503 FK violation. let payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields) if ( isCreate && branchGate && (payload.branch_id == null || payload.branch_id === '') ) { const branchId = await branchGate.ensureBranchForCreate(model) if (branchId === null) return if (branchId) payload = { ...payload, branch_id: branchId } } setSaving(true) try { if (isCreate && onCreate) { const created = await onCreate(payload) toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })) onSaved?.(created ?? undefined) onOpenChange(false) return } if (!isCreate && recordId && onUpdate) { const updated = await onUpdate(String(recordId), payload) toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })) onSaved?.(updated ?? undefined) onOpenChange(false) return } let res if (isCreate) { const createEndpoint = endpoint || `/dynamic/${model}` res = await api.post(createEndpoint, payload) } else { const updateEndpoint = endpoint ? `${endpoint}/${recordId}` : `/dynamic/${model}/${recordId}` res = await api.put(updateEndpoint, payload) } if (res.data?.success !== false) { // Prefer the addon's localized message (modal metadata), then a // localized fallback. NOT res.data.message — the dynamic CRUD // endpoint returns a raw English string that would leak into the toast. toast.success( modalMeta?.messages?.[isCreate ? 'created' : 'updated'] || (isCreate ? t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }) : t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })), ) // Hand the persisted record back so callers can auto-select it. onSaved?.(res.data?.data ?? res.data ?? undefined) onOpenChange(false) } else { // Surface the server's real cause (`details`) as the toast // description, not just the generic headline. `res.data` is the // `{ success:false, message, details }` envelope. handleSubmitError(res.data) } } catch (err: any) { handleSubmitError(err) } finally { setSaving(false) } } const handleDelete = async () => { if (!onDelete) return setDeleting(true) try { await onDelete() onOpenChange(false) } catch (err: any) { console.error('[DynamicRecordDialog] delete error:', err) toastServerError(err, { t, fallback: t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }) }) } finally { setDeleting(false) } } const title = modalMeta ? config.getTitle(modalMeta, t) : mode === 'create' ? 'Nuevo registro' : mode === 'edit' ? 'Editar registro' : 'Ver registro' const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues) // Declarative form layout: group the (already visibility-filtered) fields by // their section. Empty sections drop out for free. Steps mode only drives a // wizard in editable modes — view mode always stacks the sections. const formLayout = modalMeta?.form_layout ?? modalMeta?.formLayout const groups = groupFieldsBySection(visibleFields, formLayout, formValues) const isSteps = isEditable && formLayout?.mode === 'steps' && groups.length > 1 const clampedStep = Math.min(stepIndex, Math.max(groups.length - 1, 0)) const isLastStep = clampedStep === groups.length - 1 // Renders a list of fields into the shared two-column grid (each FieldCell // gives min-w-0 so long values can't blow the columns past the dialog). const renderFields = (groupFields: FieldDef[]) => groupFields.map(field => { const isFullWidth = field.type === 'textarea' || field.widget === 'textarea' || field.widget === 'richtext' return ( { setFormValues((prev: Record) => ({ ...prev, [field.key]: val })) setFieldErrors(prev => { if (!prev[field.key]) return prev const next = { ...prev } delete next[field.key] return next }) }} /> ) }) // Wizard "Siguiente": gate only the CURRENT step's required (visible) fields, // then advance. Mirrors handleSubmit's required check but scoped to the step. const goNextStep = () => { const step = groups[clampedStep] const stepFields = step?.fields ?? [] const bag = validateValues(stepFields as ActionFieldDef[], formValues) if (bagHasErrors(bag)) { const labels: Record = {} for (const f of stepFields) labels[f.key] = f.label setFieldErrors(localizeFieldErrorMap(bag, t, { labels })) toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' })) return } setFieldErrors({}) setStepIndex(Math.min(clampedStep + 1, groups.length - 1)) } const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0)) return ( {title} {config.description}
{loading ? ( ) : modalMeta ? ( {/* The form element groups its fields by the declared form_layout (sections stacked, or the current wizard step). Without a layout this is a single default group rendered with no chrome — the legacy two-column grid, unchanged. FieldCell gives each cell `min-w-0` so a long select/input value can't blow the two columns past the dialog width. */}
{isSteps && ( setStepIndex(i)} /> )} {(isSteps ? [groups[clampedStep]] : groups).map(group => // An assisted step IS the interview: no form inputs, the // provider asks what it needs and fills the fields. isSteps && isEditable && group.assist ? ( setFormValues(prev => ({ ...prev, ...fields }))} /> ) : (
{renderFields(group.fields)}
{isEditable && group.assist && ( setFormValues(prev => ({ ...prev, ...fields }))} /> )}
), )} {record?.external_url && ( )} {/* Líneas del documento: SOLO las relaciones de composición (`embed`). El resto de las 1:N — existencias, traspasos, cualquier colección grande de gestión independiente — vive en su propia página, no adentro de este formulario. View = strictly read-only; edit = add/edit/delete. */} {!isCreate && record && relations.length > 0 && (
)}
) : null}
{isView && onOpenFullPage ? ( ) : }
{/* Wizard "Anterior" replaces Cancel from the second step on; the first step still shows Cancel. */} {isSteps && clampedStep > 0 ? ( ) : ( )} {isView && onDelete && ( )} {isView && onEdit && ( )} {/* Non-final wizard step → "Siguiente" validates the step and advances instead of submitting. The last step (and every non-wizard form) keeps the real submit button. */} {isEditable && isSteps && !isLastStep && ( )} {isEditable && (!isSteps || isLastStep) && ( )}
) } function LoadingSkeleton() { return (
{Array.from({ length: 6 }).map((_, i) => (
))}
) } interface FieldRowProps { field: FieldDef record: any value: any mode: 'view' | 'edit' | 'create' onChange: (val: any) => void /** Localized validation error for this field, shown in red under the input. */ error?: string /** * Caller-forced lock (via `lockedFields`), independent of `field.readonly`. * Renders the same disabled/muted input as an edit-mode readonly field, but * applies on CREATE — where a plain `readonly` field would be excluded * instead. The seeded `value` (from `defaults`) still submits. */ locked?: boolean } function FieldRow({ field, record, value, mode, onChange, error, locked }: FieldRowProps) { // A `readonly` field is server/system-generated (e.g. the GitHub addon's // `number`/`github_url`, filled by the API after the outbound create). On // CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT // it stays visible but is NOT editable — rendered as a disabled, muted input // so the user sees its value without being able to change it. View mode keeps // the rich read-only renderer. `locked` forces the same disabled rendering on // CREATE for a caller-specified field (see `lockedFields`). const isEditReadonly = (mode === 'edit' && !!field.readonly) || !!locked return (
{mode === 'view' ? ( ) : isEditReadonly ? ( ) : ( )} {error && mode !== 'view' && (

{error}

)}
) } // ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated) // field: a disabled, muted input that shows the current value without allowing // edits. Booleans render as a disabled switch to match their editable // counterpart; everything else renders the formatted display value in a disabled // text input. export function ReadonlyEditField({ field, value }: { field: FieldDef; value: any }) { if (field.type === 'boolean' || typeof value === 'boolean') { return (
{value ? 'Sí' : 'No'}
) } const fieldRef = getFieldRef(field as ActionFieldDef) if (fieldRef || field.searchEndpoint) { return } const display = formatDisplayValue(value, field) return } // ReadonlyRelationField — a locked/readonly FK field (customer_id, category_id…) // resolves the record's label instead of showing the raw id, mirroring // RelationViewValue's lookup but rendered as a disabled input to match the rest // of ReadonlyEditField. function ReadonlyRelationField({ field, value, fieldRef, }: { field: FieldDef value: any fieldRef?: string }) { const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value const needResolve = fieldRef != null || !!field.searchEndpoint const { options } = useOptionsResolver({ modelKey: '', fieldKey: 'id', ref: fieldRef, endpoint: fieldRef ? undefined : field.searchEndpoint, query: '', limit: 50, enabled: needResolve && rawVal != null && rawVal !== '', }) const resolved = options.find(o => String(o.id) === String(rawVal)) const display = resolved?.label ?? (rawVal != null && rawVal !== '' ? String(rawVal) : '') return } // RelationViewValue — read-only FK lead. Resolves the relation's label + image // from (1) the sibling object the table served, then (2) the canonical options // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label. // When `stack` is true (`display: "image_stack"`), the landscape mark sits ON // TOP of the label — wide logos (brand marks) fit without cropping. function RelationViewValue({ field, value, record, stack = false, }: { field: FieldDef value: any record: any stack?: boolean }) { const getImageUrl = useContext(ImageUrlContext) const sib = relationSiblingValue(field, record) const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib) const sibImage = pickImage(sib) // The raw FK id, tolerating an inline resolved object as the value itself. const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value const inlineLabel = sibLabel ?? objectLabel(value) const inlineImage = sibImage ?? pickImage(value) const fieldRef = getFieldRef(field as ActionFieldDef) // Only resolve over the network when we still lack both label and image and // there is something to look up. const needResolve = !inlineLabel && !inlineImage && !!(fieldRef || field.searchEndpoint) && rawVal != null && rawVal !== '' const { options } = useOptionsResolver({ modelKey: '', fieldKey: 'id', ref: fieldRef, endpoint: fieldRef ? undefined : field.searchEndpoint, query: '', limit: 50, enabled: needResolve, }) const resolved = options.find(o => String(o.id) === String(rawVal)) const label = inlineLabel ?? resolved?.label ?? (rawVal != null && rawVal !== '' && !isNilUuid(rawVal) ? String(rawVal) : undefined) const image = inlineImage ?? resolved?.image ?? undefined if (!label && !image) { return

} if (stack) { return (
) } const lead: Pick = { image: image ? getImageUrl(image) : null, color: resolved?.color ?? null, icon: resolved?.icon ?? null, // Carry the label so an imageless reference falls back to its initials // avatar (shared InitialsAvatar via OptionLead) instead of a blank lead. label: label ?? '', } return (
{label ?? '—'}
) } export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }: { field: FieldDef value: any record: any /** Optional override; when omitted falls back to the nearest provider/identity. */ getImageUrl?: GetImageUrl /** Optional override; when omitted falls back to the nearest provider. */ timeZone?: string /** Optional override; when omitted falls back to the nearest provider. */ currency?: string }) { const { t, i18n } = useTranslation() const ctxImageUrl = useContext(ImageUrlContext) const ctxTimeZone = useContext(TimeZoneContext) const ctxCurrency = useContext(CurrencyContext) const getImageUrl = getImageUrlProp ?? ctxImageUrl const timeZone = timeZoneProp ?? ctxTimeZone const currency = currencyProp ?? ctxCurrency // Declarative display hint the backend stamps (mirrors the table column's // `cellStyle`). The table renders each cell off `cellStyle ?? type`; the // detail view keys off the SAME resolved renderer so both stay in lock-step // (a `datetime` display on a numeric column, a `url` display on a text // column, a `status`/`badge` pill, …). const renderAs = field.cellStyle ?? field.type // created_by / avatar resolver sibling → name (+ avatar) instead of "—". if ( field.type === 'avatar' || renderAs === 'avatar' || renderAs === 'creator' || renderAs === 'user' || field.key === 'created_by' || field.key === 'created_by_id' ) { const user = createdBySibling(rawValue, record) if (user) { return (
{user.avatar ? ( {user.name ) : null} {user.name ?? user.email ?? '—'}
) } // Null created_by on a creator/created_by field = system actor, same // contract as the table cell (resolveMissingActorLabel). const isCreatedBy = field.key === 'created_by' || field.key === 'created_by_id' || (typeof field.key === 'string' && field.key.startsWith('created_by.')) || renderAs === 'creator' if (isCreatedBy) { return

Sistema

} return

} // Nil/zero UUID (unset nullable FK serialized as all-zeros) → empty marker. if (isNilUuid(rawValue)) { return

} const value = normalizeNilUuid(rawValue) // Landscape stack on a relation FK (brand marks, product cards): image ON // TOP, label UNDERNEATH. Checked before the default relation lead so a // `display: "image_stack"` FK does not fall through to the side-by-side chip. if (renderAs === 'image_stack' && looksLikeForeignKey(field)) { return } // Relation (search / dynamic_select / ref / uuid *_id FK) → resolved // thumbnail + label. Plain text `*_id` columns (external_id, …) stay text. if (looksLikeForeignKey(field)) { return } // The value is itself a resolved object the backend served inline — render // its label/name, never the raw JSON. const inlineLabel = objectLabel(value) if (inlineLabel !== undefined) { return

{inlineLabel}

} if (field.type === 'boolean' || typeof value === 'boolean') { return (
{value ? 'Sí' : 'No'}
) } if (field.type === 'color') { return value ? (
{value}
) : (

-

) } // Landscape stack for image/logo URL columns (and `type: image` with // `cellStyle: image_stack`). Wide marks sit above an optional caption. if (renderAs === 'image_stack') { if (value && isLucideIconName(value)) { return } const labelField = (field.styleConfig && (field.styleConfig.label_field as string | undefined)) || (field.styleConfig && (field.styleConfig.labelField as string | undefined)) let caption: string | undefined if (labelField && record && typeof record === 'object') { const raw = (record as Record)[labelField] if (raw != null && String(raw) !== '') caption = String(raw) } return value || caption ? (
) : (

Sin imagen

) } if (field.type === 'image' || renderAs === 'image') { if (isLucideIconName(value)) { return } return value ? ( {field.label} ) : (

Sin imagen

) } // Icon-name column served as plain text (the table infers cellStyle image, // but the detail/modal field keeps the storage type): render the glyph. if ( isLucideIconName(value) && typeof field.key === 'string' && (field.key === 'icon' || field.key.endsWith('_icon')) ) { return } // URL/link display (matches the table's `url`/`link` cell). Triggers on the // stamped display type — not just the storage `type` — so a text column // carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable // external link, opening in a new tab, truncated. if ((renderAs === 'url' || renderAs === 'link') && value) { // Shared media renderer (same as the table cell): an image URL shows a // larger inline thumbnail, a file a chip, else a compact link chip with // a smart label — never the raw 120-char URL. return (
) } // Money → org-currency string. Detected by the backend `cellStyle:'currency'` // stamp or a numeric value whose key matches the money heuristic (fallback // mirroring the table cell + backend `inferDisplayCellStyle`). if (isMoneyField(field, value)) { const num = typeof value === 'number' ? value : Number(value) if (!isNaN(num)) { const resolvedCurrency = field.styleConfig?.currency || currency || 'USD' const localeTag = i18n.language || 'es' const formatted = new Intl.NumberFormat(localeTag, { style: 'currency', currency: resolvedCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(num) return

{formatted}

} } // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar // day); instants render in the org timezone with a full-precision tooltip. // Keys off the display type (`cellStyle ?? type`) so a numeric/epoch column // stamped `datetime` (e.g. `synced_at`) formats as a date, never raw digits. if ( renderAs === 'date' || renderAs === 'datetime' || renderAs === 'timestamp' || renderAs === 'timestamptz' ) { const dateRenderAs = renderAs === 'date' ? 'date' : renderAs const formatted = formatDateCell(value, dateRenderAs, es, timeZone) if (formatted) { return (

{formatted.display}

) } return

} // Enum/option field with served options → the SAME colored/iconed pill the // table renders (shared `OptionBadge`): resolved color, thumbnail/icon and // the localized option label (e.g. "Almacenable" instead of "storable"). const opt = servedOption(field, value) if (opt) { return (
) } // Array of scalars / label objects (e.g. github `labels`, tags, a // group-badge list) → a row of pills, mirroring the table's `tags` / // `relation-badge-list` cells. Checked before the structured-object branch // so a flat label array never renders as a mini-table. if ( Array.isArray(value) && (renderAs === 'tags' || renderAs === 'relation-badge-list' || value.every((v) => v === null || typeof v !== 'object' || 'label' in v || 'name' in v)) ) { return } // Status / badge / select display with no served option list — a bare enum // token (e.g. a kanban `stage` like "backlog"). Render a colored pill with a // semantic/value-derived color and a localized-or-humanized label, matching // the table's `status`/`badge` cells. if ( (renderAs === 'status' || renderAs === 'badge' || renderAs === 'select' || renderAs === 'option') && value !== null && value !== undefined && typeof value !== 'object' ) { return } // Structured value (jsonb column, e.g. fiscal_data) with no label/name/title // to surface — render readable key/value pairs instead of falling through to // String(value) ("[object Object]"). if (value !== null && typeof value === 'object') { return ( ) } const display = formatDisplayValue(value, field) // Free text may embed URLs (a github body, notes, a long-text field). Turn // them into rich chips / inline thumbnails with the shared linkifier instead // of showing raw URLs — the rest of the text is preserved verbatim. const hasUrl = display !== '—' && /(https?:\/\/|www\.)\S/i.test(display) if (field.type === 'textarea' || renderAs === 'textarea' || renderAs === 'long-text') { return (

{hasUrl ? ( ) : ( display )}

) } return (

{hasUrl ? ( ) : ( display )}

) } // IconNameViewValue — read view for a column whose value is a lucide icon name // (an addon's `icon` column): the glyph plus the name, so the value stays // copyable/recognizable next to its rendering. function IconNameViewValue({ name }: { name: string }) { return (
{name}
) } // StatusBadgeViewValue — a bare enum/status token (no served option list) as a // colored pill: a semantic/value-derived color (same `statusColorFor` the table // uses) plus a localized-or-humanized label. Mirrors the table's `status`/ // `badge` cell so a kanban `stage` ("backlog") reads as a colored, translated // badge instead of the raw token. function StatusBadgeViewValue({ field, value, t, }: { field: FieldDef value: any t: (key: string, options?: any) => string }) { const isDark = useIsDarkTheme() const token = String(value) // Prefer an explicit per-option color served on the field (metadata.stages / // options), else derive a semantic color from the token. const declared = field.options?.find((o) => String(o.value) === token) const color = declared?.color || statusColorFor(token) // Localized label: the option's already-localized label wins, then a manifest // i18n key matching the raw token, then a humanized fallback. const label = declared?.label ?? t(token, { defaultValue: humanizeToken(token) }) return (
{label}
) } // BadgeListViewValue — an array of scalars / label objects as a row of pills // (github `labels`, tags, a group-badge list). Objects carrying a `color` render // as colored `OptionBadge`s; plain strings render as neutral secondary pills. function BadgeListViewValue({ items, getImageUrl, }: { items: any[] getImageUrl: GetImageUrl }) { if (!items || items.length === 0) { return

} return (
{items.map((item, i) => { if (item !== null && typeof item === 'object') { const opt: DisplayOption = { value: String(item.value ?? item.id ?? item.name ?? item.label ?? i), label: String(item.label ?? item.name ?? item.value ?? ''), color: item.color ?? undefined, icon: item.icon ?? undefined, image: item.image ?? item.avatar ?? undefined, } return } return ( {String(item)} ) })}
) } // StructuredViewValue renders a jsonb object/array that has no resolvable label. // It delegates to the shared `CollectionCell` in `'inline'` mode so the detail // view gets the SAME pro rendering as the table: a declared `item_fields` schema // drives localized headers + resolved ref labels (the injected `{value,label}` // sibling) for line-items; without a schema it falls back to a localized // key→value pair list / mini-table — never raw `JSON.stringify`. Empty arrays / // empty objects keep the "—" marker (CollectionCell renders a muted dash, which // we normalize to the em-dash the detail view uses elsewhere). function StructuredViewValue({ value, field, locale, t, }: { value: any field?: FieldDef locale?: string t?: (key: string, options?: any) => string }) { const getImageUrl = useContext(ImageUrlContext) const isEmpty = value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0) || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) if (isEmpty) { return

} // Line-items arrays with a declared itemFields schema → mini-table. // Plain objects (PAC provider_data, fiscal_data bags) → readable key/value // list; nested objects/arrays render as pretty JSON instead of `key: {…}` // stubs that looked broken in fiscal detail modals. const hasItemFields = !!(field?.itemFields ?? field?.item_fields) if ( !hasItemFields && value !== null && typeof value === 'object' && !Array.isArray(value) ) { return } /> } return (
) } /** Flatten a jsonb/object bag into labeled rows; nest as pretty JSON. */ function JsonObjectViewValue({ value }: { value: Record }) { const entries = Object.entries(value).filter(([, v]) => v !== undefined) if (entries.length === 0) { return

} return (
{entries.map(([key, raw]) => { const label = humanizeToken(key) const isNest = raw !== null && typeof raw === 'object' && !(raw instanceof Date) return (
{label}
{isNest ? (
                                    {JSON.stringify(raw, null, 2)}
                                
) : raw === null || raw === '' ? ( ) : ( String(raw) )}
) })}
) } export function EditField({ field, value, onChange, record, invalid }: { field: FieldDef value: any onChange: (val: any) => void /** The full record being edited — supplies FK relation siblings + line-items. */ record?: any /** When true, paint the control with a destructive border (Laravel-style). */ invalid?: boolean }) { const invalidCls = invalid ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive aria-invalid:border-destructive' : undefined const { t, i18n } = useTranslation() const editFieldImageUrl = useContext(ImageUrlContext) const dialogModel = useContext(RecordDialogModelContext) // Jsonb line-items columns (e.g. Transfer.items) are action-built documents: // editing the array field-by-field is out of scope. Render them READ-ONLY // with the same inline table the detail view uses — a localized, ref-resolved // mini-table — instead of an input that stringifies to "[object Object]". if (isLineItemsField(field, value)) { return (

{t('datatable.readOnly', { defaultValue: 'Solo lectura' })}

) } if (field.type === 'boolean') { return (
{value ? 'Sí' : 'No'}
) } if (field.type === 'textarea') { return (