// ActionModalDispatcher — renders the right modal for a custom action: // 1) Custom component from the SDK registry → use it // 2) action.modal set but no registered component → loading state while the // host loads the remote (setFederatedActionLoader); the registry is reactive, // so the component renders the moment it registers. Timeout → // MissingCustomActionModal (a malformed slug still asks the host, then // times out). NEVER falls back to confirm/fields — that // hides a broken federated UI. // 3) action.fields[] / action.steps[] → GenericActionModal / WizardActionModal // 4) action.confirm OR action.confirmMessage → ConfirmActionDialog // 5) action.executable (host opened the modal; no modal/fields/confirm) → // ConfirmActionDialog so a click never silently no-ops // 6) otherwise → null (caller should execute immediately without opening us) // // The host injects its axios-like client via ; we no longer // depend on a bundler alias to `@/lib/api`. import { emitRecordMutationSettled } from './record-mutation-events' import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, Input, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, } from '@asteby/metacore-ui/primitives' import { Loader2 } from 'lucide-react' import { ProcessStepper } from '@asteby/metacore-ui/wizard' import { toast } from 'sonner' import { toastServerError, toastServerSuccess, extractFieldErrors, extractServerError, localizeFieldErrorMap } from './server-error' import { FormErrorBanner } from './business/feedback' import { CfdiStampResultDialog, extractStampResult, type CfdiStampResult } from './cfdi-stamp-panel' import { useBranchCreateGate } from './branch-create-gate' import type { Translate } from './server-error' import { validateValues, bagHasErrors } from './validator' import { clearFieldErrorTree, formatFieldErrorsDescription, labelsForValidationFields, labelForValidationPath, lineItemErrorsFor, } from './field-validation-ui' import { validationCatalog } from './validation-catalog' import { useApi } from './api-context' import { useReasonPrompt } from './reason-prompt' import { useSupervisor, withApproval } from './supervised-action' import { buildRelationFilterParams } from './dynamic-relation-helpers' import { DynamicIcon } from './dynamic-icon' import { DynamicLineItems } from './dynamic-line-items' import { DynamicRelations } from './dynamic-relations' import { DynamicSelectField } from './dynamic-select-field' import { DynamicDateField } from './dynamic-date-field' import { UploadField } from './upload-field' import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn, getFieldRef, applyOptionWhen } from './dynamic-form-schema' import { FieldGrid, FieldCell, FieldLabel } from './field-grid' import { useMetadataCache } from './metadata-cache' import { ViewValue, objectLabel, relationSiblingValue, fieldItemFields, isLineItemsField as isRecordLineItemsField, } from './dialogs/dynamic-record' import type { ActionFieldDef, TableMetadata, ColumnDefinition } from './types' // Canonical registry lives in @asteby/metacore-sdk import { type ActionMetadata, type ActionModalProps, type BridgedActionProps, adaptActionProps, getActionComponent, subscribeActionComponents, } from '@asteby/metacore-sdk' import { requestFederatedAction } from './federated-action-loader' import { useInstalledAddons } from './installed-addons-context' import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions' import { installedPredicate, useContributionsVersion } from './primitives/use-contributions' import { isTodayToken, todayInZone } from './calendar-date' import { useTimeZone } from './org-runtime-context' export type { ActionMetadata, ActionModalProps } // ---- line-items prefill from the acted-on record ---------------------------- // // A line-items action field can seed its rows from the record being acted on, // instead of opening empty. The manifest declares this by setting the field's // `default`/`defaultValue` to a PrefillSpec object: the modal reads the record's // `$prefillFromRecord` array, copies the mapped keys, and (for a receive-style // flow) computes a `remaining` quantity = of - minus, dropping fully-satisfied // rows. Decoupled + generic: the SDK knows nothing about transfers, only how to // project a record array into the field's item_fields. Example (receive goods): // // "default": { // "$prefillFromRecord": "items", // "map": { "product_id": "product_id" }, // "remaining": { "target": "qty_received", "of": "quantity", "minus": "received" } // } export interface PrefillSpec { $prefillFromRecord: string map?: Record remaining?: { target: string; of: string; minus?: string } /** * Item-field keys to lock (render read-only) in the prefilled rows — e.g. the * product of a receive-goods line is dictated by the source document, so it * is shown as a resolved name but cannot be changed. Editable in the create * flow (which carries no prefill spec); only the prefilled action locks them. */ lock?: string[] /** * Key of a SIBLING action field (a dynamic_select with a `ref`) whose selected * value is the id of the document the rows are seeded from. Without it the * rows come from the record the action was opened on (row actions); with it * a `create`-placed action — which has no record — seeds its grid as soon as * the user picks the source document (e.g. a goods receipt picking its * purchase order). `$prefillFromRecord` then names a one_to_many relation of * the REFERENCED model. Clearing the selection empties the grid. */ fromField?: string } export function isPrefillSpec(v: unknown): v is PrefillSpec { return ( typeof v === 'object' && v !== null && typeof (v as { $prefillFromRecord?: unknown }).$prefillFromRecord === 'string' ) } // lineItemsDefault reads the field's declared default tolerating BOTH the // camelCase `defaultValue` the host serves and the snake/legacy `default` the // raw manifest carries (the kernel maps action-field `default` through without // renaming it to `defaultValue`). function lineItemsDefault(field: ActionFieldDef): unknown { const f = field as { defaultValue?: unknown; default?: unknown } return f.defaultValue ?? f.default } function toNum(v: unknown): number { const n = typeof v === 'number' ? v : parseFloat(String(v ?? '')) return Number.isFinite(n) ? n : 0 } // applyPrefillLock marks the item-field columns named in a line-items field's // PrefillSpec.lock as read-only, so the prefilled cells (e.g. the product of a // receive line) render as a resolved, non-editable name. Returns the field // untouched when there is no prefill spec or no lock list (the create flow, // which carries no prefill, stays fully editable). The readonly flag is set on // BOTH itemFields aliases the renderers tolerate. export function applyPrefillLock(field: ActionFieldDef): ActionFieldDef { const spec = lineItemsDefault(field) if (!isPrefillSpec(spec) || !spec.lock || spec.lock.length === 0) return field const lock = new Set(spec.lock) const f = field as ActionFieldDef & { itemFields?: any[]; item_fields?: any[] } const items: any[] | undefined = f.itemFields ?? f.item_fields if (!Array.isArray(items)) return field const patched: any[] = items.map((c) => (c && c.key && lock.has(c.key) ? { ...c, readonly: true } : c)) return { ...(field as any), itemFields: patched, item_fields: patched } as ActionFieldDef } // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows. export function buildPrefillRows(spec: PrefillSpec, record: any): Array> { const src = record?.[spec.$prefillFromRecord] if (!Array.isArray(src)) return [] const rows: Array> = [] for (const item of src) { if (!item || typeof item !== 'object') continue const row: Record = {} if (spec.map) { for (const [target, from] of Object.entries(spec.map)) row[target] = item[from] } if (spec.remaining) { const remaining = toNum(item[spec.remaining.of]) - (spec.remaining.minus ? toNum(item[spec.remaining.minus]) : 0) if (remaining <= 0) continue // line already fully satisfied → omit row[spec.remaining.target] = remaining } rows.push(row) } return rows } // ---- prefill lines from the record's declared relation ---------------------- // // A row action opened from a LIST gets the list row, which does not carry the // document's lines: `record.items` is undefined and a receive modal opened // with "Sin renglones" (QA 7Leguas: OC receive modal empty, the user could not // see what was being received). When the prefill source key is missing from // the record but the model declares a one_to_many relation with that name, the // modal fetches the child rows the same way the relation panel does // (`/data/?f_=eq:`) and builds the rows from them. export interface PrefillRelationRequest { fieldKey: string spec: PrefillSpec endpoint: string params: Record } export function prefillRelationRequests( fields: ActionFieldDef[] | undefined, record: any, relations: Array<{ name?: string; kind?: string; through?: string; foreign_key?: string; scope?: Record }>, ): PrefillRelationRequest[] { const out: PrefillRelationRequest[] = [] const recordId = record?.id if (!fields || recordId === undefined || recordId === null || recordId === '') return out for (const field of fields) { if (!isLineItemsField(field)) continue const spec = lineItemsDefault(field) if (!isPrefillSpec(spec)) continue if (Array.isArray(record?.[spec.$prefillFromRecord])) continue // already embedded const rel = (relations || []).find( (r) => r?.name === spec.$prefillFromRecord && (r.kind ?? 'one_to_many') === 'one_to_many', ) if (!rel?.through || !rel.foreign_key) continue const scope: Record = {} for (const [k, v] of Object.entries(rel.scope ?? {})) { if (v !== undefined && v !== null) scope[k] = String(v) } out.push({ fieldKey: field.key, spec, endpoint: `/data/${rel.through}`, params: { ...buildRelationFilterParams(rel.foreign_key, recordId, scope), per_page: 200 }, }) } return out } // ---- prefill lines from a document picked in a sibling field ---------------- // // A create-placed action has no record, so `$prefillFromRecord` alone can only // seed an empty grid. With `fromField` the spec follows a sibling ref field: // when the user selects the source document the modal loads its relation rows // and seeds the grid (see PrefillSpec.fromField). export interface PrefillFromFieldRequest { fieldKey: string spec: PrefillSpec /** Model key the sibling field references (its `ref`). */ refModel: string /** Selected document id; '' when nothing is selected (grid must clear). */ refId: string } export function prefillFromFieldRequests( fields: ActionFieldDef[] | undefined, formData: Record, ): PrefillFromFieldRequest[] { const out: PrefillFromFieldRequest[] = [] if (!fields) return out for (const field of fields) { if (!isLineItemsField(field)) continue const spec = lineItemsDefault(field) if (!isPrefillSpec(spec) || !spec.fromField) continue const source = fields.find((f) => f.key === spec.fromField) const refModel = source ? getFieldRef(source) : undefined if (!refModel) continue const raw = unwrapRecordScalar(formData?.[spec.fromField]) const refId = raw === undefined || raw === null ? '' : String(raw) out.push({ fieldKey: field.key, spec, refModel, refId }) } return out } // prefillFromFieldRelationRequest resolves the child-rows request for a // selected document, from the referenced model's declared relations. Null when // the relation is missing or not a plain one_to_many. export function prefillFromFieldRelationRequest( req: PrefillFromFieldRequest, relations: Array<{ name?: string; kind?: string; through?: string; foreign_key?: string; scope?: Record }>, ): { endpoint: string; params: Record } | null { if (!req.refId) return null const rel = (relations || []).find( (r) => r?.name === req.spec.$prefillFromRecord && (r.kind ?? 'one_to_many') === 'one_to_many', ) if (!rel?.through || !rel.foreign_key) return null const scope: Record = {} for (const [k, v] of Object.entries(rel.scope ?? {})) { if (v !== undefined && v !== null) scope[k] = String(v) } return { endpoint: `/data/${rel.through}`, params: { ...buildRelationFilterParams(rel.foreign_key, req.refId, scope), per_page: 200 }, } } // ---- scalar prefill from the acted-on record -------------------------------- // // Row actions (stamp / refactura / cancel-with-reason) should open with the // current record's values, not empty selects. Manifest declares explicit paths // via `defaultFromRecord` (string or string[] fallback chain). When omitted, // the field seeds from record[field.key] if present. export function unwrapRecordScalar(value: unknown): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object' || value instanceof Date) return value if (Array.isArray(value)) return value const o = value as Record if ('value' in o && (typeof o.value === 'string' || typeof o.value === 'number')) { return o.value } if ('id' in o && (typeof o.id === 'string' || typeof o.id === 'number')) { return o.id } return value } export function readRecordPath(record: any, path: string): unknown { if (!record || !path) return undefined const parts = path.split('.') let cur: any = record for (const p of parts) { if (cur == null || typeof cur !== 'object') return undefined cur = cur[p] } return unwrapRecordScalar(cur) } function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undefined { const f = field as ActionFieldDef & { defaultFromRecord?: string | string[] default_from_record?: string | string[] } return f.defaultFromRecord ?? f.default_from_record } /** `customers.Invoice`, `Invoice`, `invoices`, `sales_orders` → forma comparable. */ function modelToken(m: string): string { const last = m.split('.').pop() ?? m return last.replace(/[^a-z0-9]/gi, '').toLowerCase() } /** * ¿`ref` (FK de un campo) apunta al modelo `model` de la fila? Tolera prefijo de * addon, PascalCase vs tabla y el plural de la tabla (`Invoice` ↔ `invoices`, * `CustomerAddress` ↔ `customer_addresses`). */ export function refTargetsModel(ref: string | undefined | null, model: string | undefined | null): boolean { if (!ref || !model) return false const a = modelToken(ref) const b = modelToken(model) if (!a || !b) return false return a === b || a + 's' === b || a + 'es' === b || b + 's' === a || b + 'es' === a } function fieldRef(field: ActionFieldDef): string | undefined { if (field.ref) return field.ref const opts = (field as { options?: unknown }).options if (opts && !Array.isArray(opts) && typeof opts === 'object') { const src = (opts as { source?: unknown }).source if (typeof src === 'string' && src) return src } return undefined } /** * Scalar seed for one action field from the row being acted on. * * Sin `default_from_record` declarado, un campo se siembra con la columna * homónima de la fila; y un selector que apunta al MISMO modelo de la fila y * que la fila no tiene como columna (`invoice_id` → Invoice en «Registrar pago» * de una factura) se siembra con el id de esa fila: la acción se abrió desde * ella (retest Pitsline 2026-10-05). */ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any, model?: string): unknown { if (!record) return undefined const spec = defaultFromRecordSpec(field) if (typeof spec === 'string') { const v = readRecordPath(record, spec) if (v !== undefined && v !== null && v !== '') return v } else if (Array.isArray(spec)) { for (const path of spec) { const v = readRecordPath(record, path) if (v !== undefined && v !== null && v !== '') return v } } else if (field.key) { const v = readRecordPath(record, field.key) if (v !== undefined && v !== null && v !== '') return v // Sólo si la fila NO tiene esa columna: un `parent_id` vacío de una // categoría es «sin padre», no «yo mismo». const id = unwrapRecordScalar(record.id) if (!(field.key in record) && id !== undefined && id !== null && id !== '' && refTargetsModel(fieldRef(field), model)) { return id } } return undefined } export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess: onSuccessProp, }: ActionModalProps) { // Every successful action may have created/updated/deleted a record of // `model` (federated component, wizard, generic form or confirmation), so // announce it: mounted lists of the model reload on their own (#1020) — // and again once the server's event subscribers had time to write what the // action triggers (estado tras timbrar, REP tras un pago). const onSuccess = useCallback(() => { emitRecordMutationSettled(model, 'update') onSuccessProp?.() }, [model, onSuccessProp]) // Reactive read: a federated remote usually registers AFTER the first // render, and a memoized lookup would pin the fallback forever. const readComponent = () => getActionComponent(model, action.key) const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent) // Modal federado declarativo (registerFederatedModal). Si el addon dueño // del slug consta como NO instalado, el `modal` se ignora y se usa el // formulario genérico de la acción de inmediato (sin esperar el timeout). useContributionsVersion() const installed = useInstalledAddons() const slug = action.modal || '' const federatedModal = slug ? resolveFederatedModal(slug, installedPredicate(installed)) : null const modalAddonMissing = !!slug && !federatedModal && !!installed && !installed.addons.has(slug.split('.')[0]) if (CustomComponent) { return ( ) } if (federatedModal) { // Modal por slug del store canónico: lo registró `api.registry.registerModal` // (contrato recordId/payload/close) o registerFederatedModal // (ActionModalProps). Recibe ambos contratos. const FederatedComponent = federatedModalComponent(federatedModal) return ( }> ) } // Declarative custom slot (`modal: "addon.action"`). Wait for the remote, // then a hard error — confirm/fields generics look "fine" and hide a // missing remote. Exception: the slug's addon is known NOT installed — the // remote will never come, so fall through to the generic form now. if (action.modal && !modalAddonMissing) { return ( ) } if (action.steps && action.steps.length > 0) { return ( ) } if (action.fields && action.fields.length > 0) { return ( ) } // confirm_message alone is confirmation intent (v3 manifests often omit the // boolean). Hosts also mark every wasm action `executable` and open THIS // dispatcher; without a confirm/fields/custom UI we used to return null and // the row click did nothing. Treat message-only + executable-open as confirm. // Never reached when action.modal is set (handled above). const wantsConfirm = !!(action.confirm || action.confirmMessage) if (wantsConfirm || (open && action.executable)) { return ( ) } return null } /** How long an action with `modal` waits for its federated component. */ export const FEDERATED_ACTION_MODAL_TIMEOUT_MS = 20_000 /** How often the host loader is re-invoked while waiting. */ const FEDERATED_ACTION_RETRY_MS = 2_000 const MODAL_SLUG_RE = /^[\w-]+(\.[\w-]+)+$/ /** * `modal` declared, component not registered yet: show a loading dialog, ask * the host to load the remote (re-asking periodically), and give up with * MissingCustomActionModal after the timeout. The dispatcher swaps this out * for the real component as soon as the registry reports it. `modal` is * honored wherever it came from — the addon's own manifest or a host-injected * override from another addon that registerAction()s over it. */ function PendingCustomActionModal({ open, onOpenChange, action, model, }: { open: boolean onOpenChange: (open: boolean) => void action: ActionMetadata model: string }) { const slug = action.modal || '' const [timedOut, setTimedOut] = useState(false) useEffect(() => { if (!open) return // A slug without a valid `.` prefix still goes to the // host loader (it decides which remote to load) and still times out — // the component is looked up by (model, action.key), never by slug. // Warn so a malformed manifest is diagnosable before the timeout. const validSlug = MODAL_SLUG_RE.test(slug) if (!validSlug) { console.warn('[metacore] action modal slug has no valid "." prefix', { model, action: action.key, modal: slug, }) } setTimedOut(false) const req = { model, actionKey: action.key, modal: slug } requestFederatedAction(req) const retry = setInterval(() => requestFederatedAction(req), FEDERATED_ACTION_RETRY_MS) const giveUp = setTimeout(() => { clearInterval(retry) console.error('[metacore] federated action modal did not register in time', { model, action: action.key, modal: slug, validSlug, timeoutMs: FEDERATED_ACTION_MODAL_TIMEOUT_MS, }) setTimedOut(true) }, FEDERATED_ACTION_MODAL_TIMEOUT_MS) return () => { clearInterval(retry) clearTimeout(giveUp) } }, [open, model, action.key, slug]) if (timedOut) { return } return } /** Diálogo «Cargando…» mientras llega el componente federado de la acción. */ function LoadingActionDialog({ open, onOpenChange, action, }: { open: boolean onOpenChange: (open: boolean) => void action: ActionMetadata }) { const { t } = useTranslation() return ( {t(action.label, { defaultValue: action.label })} {t('dynamic.action_modal_loading', { defaultValue: 'Cargando…' })}
) } /** Shown when the action declares `modal` but no federated component registered. */ function MissingCustomActionModal({ open, onOpenChange, action, model, }: { open: boolean onOpenChange: (open: boolean) => void action: ActionMetadata model: string }) { const { t } = useTranslation() const slug = action.modal || '' const title = t('dynamic.action_modal_missing_title', { defaultValue: 'No se pudo cargar el formulario', }) const description = t('dynamic.action_modal_missing_description', { defaultValue: 'Esta acción requiere una interfaz personalizada ({{slug}}) que no está registrada. Recarga la página o reinstala el módulo; no se abre un confirmatorio genérico para no confundir el flujo.', slug: slug || `${model}.${action.key}`, action: action.key, model, }) return ( {title} {description} onOpenChange(false)}> {t('common.close', { defaultValue: 'Cerrar' })} ) } function buildActionUrl(endpoint: string | undefined, model: string, recordId: string | undefined, actionKey: string) { // A create-placement (collection) action has no record yet, so `recordId` // is undefined. Omit the `/{id}` segment so the request hits the collection // route (`/data/:model/me/action/:action`) instead of the per-record route // (`/data/:model/me/:id/action/:action`), which would reject the literal // "undefined" as an invalid record ID (ops dynamic.go ExecuteAction → 400). const hasRecord = recordId != null && recordId !== '' && recordId !== 'undefined' if (endpoint) { return hasRecord ? `${endpoint}/${recordId}/action/${actionKey}` : `${endpoint}/action/${actionKey}` } return hasRecord ? `/data/${model}/me/${recordId}/action/${actionKey}` : `/data/${model}/me/action/${actionKey}` } // ── Preview automático del registro en el modal de confirmación ───────────── // // Antes de confirmar una row-action (aceptar/rechazar un traspaso, etc.) el // usuario ve un resumen compacto y read-only de QUÉ registro va a afectar, para // no confirmar a ciegas. Es 100% del SDK y genérico: se apoya en la metadata de // tabla del modelo (labels + display hints) y en los siblings de relación que la // tabla ya resolvió sobre el `record`. Se degrada solo — si no hay ni un campo // ni líneas útiles que mostrar, no renderiza nada (ni una caja vacía). // Claves de sistema/infra que nunca aportan contexto al preview. const PREVIEW_SKIP_KEYS = new Set([ 'id', 'organization_id', 'org_id', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'created_by_id', 'updated_by', 'updated_by_id', ]) // Campos escalares "de identidad" que sí vale la pena anclar en el preview // aunque no sean relación ni line-items (nombre, folio, estado, etc.). const PREVIEW_INTEREST_KEYS = new Set([ 'name', 'title', 'code', 'reference', 'folio', 'number', 'status', 'stage', 'state', ]) const PREVIEW_INTEREST_STYLES = new Set(['status', 'badge', 'currency']) const MAX_PREVIEW_FIELD_ROWS = 6 function isEmptyPreviewValue(value: any): boolean { if (value === null || value === undefined || value === '') return true if (Array.isArray(value)) return value.length === 0 if (typeof value === 'object' && !(value instanceof Date)) return Object.keys(value).length === 0 return false } interface PreviewRow { col: ColumnDefinition value: any lineItems: boolean } // selectPreviewColumns aplica la heurística compacta sobre las columnas del // modelo: relaciones resueltas a su label, campos line-items (jsonb) y un puñado // de escalares de identidad. Omite id/org/timestamps y los *_id crudos cuya // relación no resolvió a un label legible. function selectPreviewColumns(columns: ColumnDefinition[] | undefined, record: any): PreviewRow[] { if (!columns || !record) return [] const lineItemRows: PreviewRow[] = [] const fieldRows: PreviewRow[] = [] for (const col of columns) { if (!col || !col.key) continue if (col.hidden) continue if (PREVIEW_SKIP_KEYS.has(col.key)) continue const value = record[col.key] // Line-items (jsonb array como Transfer.items) → mini-tabla producto×cantidad. if (isRecordLineItemsField(col as any, value)) { if (isEmptyPreviewValue(value)) continue lineItemRows.push({ col, value, lineItems: true }) continue } // Relación (ref / search / dynamic_select / uuid *_id): solo si el sibling // resolvió a un label legible; text `*_id` (external_id) no es FK. const t = String(col.type || '').toLowerCase() const isRelation = !!getFieldRef(col as ActionFieldDef) || col.type === 'search' || col.type === 'relation' || (col as { widget?: string }).widget === 'dynamic_select' || (typeof col.key === 'string' && col.key.endsWith('_id') && (t === 'uuid' || t === 'search' || t === 'relation' || t === 'dynamic_select' || t === 'belongs_to')) if (isRelation) { const sib = relationSiblingValue(col as any, record) const label = typeof sib === 'string' ? sib : objectLabel(sib) if (!label) continue fieldRows.push({ col, value, lineItems: false }) continue } // Escalar de identidad (nombre/folio/estado/moneda…) con valor. const styleKey = col.cellStyle ?? col.type const interesting = PREVIEW_INTEREST_KEYS.has(col.key) || PREVIEW_INTEREST_STYLES.has(String(styleKey)) if (interesting && !isEmptyPreviewValue(value)) { fieldRows.push({ col, value, lineItems: false }) } } return [...fieldRows.slice(0, MAX_PREVIEW_FIELD_ROWS), ...lineItemRows] } function RecordPreview({ model, record }: { model: string; record: any }) { const { t } = useTranslation() const api = useApi() const cached = useMetadataCache((s) => s.getMetadata(model)) const setMetadata = useMetadataCache((s) => s.setMetadata) const [fetched, setFetched] = useState(null) // Sin metadata cacheada → UN fetch a /metadata/table/ (mismo patrón // que model-action-toolbar). Se guarda en el store para próximos usos. useEffect(() => { if (cached || !model) return let cancelled = false api .get(`/metadata/table/${model}`) .then((res) => { if (cancelled) return const meta = (res.data?.data ?? res.data) as TableMetadata if (meta && Array.isArray(meta.columns)) { setFetched(meta) setMetadata(model, meta) } }) .catch(() => { if (!cancelled) setFetched(null) }) return () => { cancelled = true } }, [cached, model, api, setMetadata]) const meta = cached ?? fetched const rows = useMemo(() => selectPreviewColumns(meta?.columns, record), [meta, record]) // Degradación: nada útil que mostrar → no renderiza la sección. if (rows.length === 0) return null return (
{rows.map(({ col, value, lineItems }) => { const label = t(col.label, { defaultValue: col.label }) if (lineItems) { return (
{label}
) } return (
{label}
) })}
) } /** Humanize a field key for a label fallback ("unit_price" → "Unit Price"). */ function humanizeKey(k: string): string { return k.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } /** Localize a failed action submit's per-field `errors` (422 map or `{errors}` * body) into `{ [fieldKey]: localizedMessage }`, using each field's label. * Returns undefined when there is no usable per-field map. */ function localizeActionFieldErrors( err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate, language?: string, ): Record | undefined { const map = extractFieldErrors(err) if (!map) return undefined const labels: Record = {} for (const f of fields ?? []) { labels[f.key] = f.label ? t(f.label, { defaultValue: f.label }) : humanizeKey(f.key) } return localizeFieldErrorMap(map, t, { labels, language }) } /** Toast a failed action: a summary + localized per-field lines when the server * returned a per-field `errors` map, else the standard cause-carrying toast. * Used where inline rendering isn't wired (confirm / multi-step wizard). */ function toastActionError( err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate, language?: string, ): void { const localized = localizeActionFieldErrors(err, fields, t, language) if (localized) { toast.error(t('validation.failed', { defaultValue: validationCatalog(language).failed }), { description: Object.values(localized).join('\n'), }) return } toastServerError(err, { t, language }) } /** Text for the in-dialog error banner of a failed action (PIT-046, #1023): a * 422 with per-field `errors` -> "Revisa los campos" + each localized line; any * other failure (500, network) -> the server's own message and cause. Unlike * the toast it stays on screen until the next attempt. */ export function actionErrorBannerMessage( err: unknown, fields: readonly ActionFieldDef[] | undefined, t: Translate, language?: string, ): string { const localized = localizeActionFieldErrors(err, fields, t, language) if (localized) { const head = t('validation.failed', { defaultValue: validationCatalog(language).failed }) const lines = Object.values(localized).filter(Boolean) return lines.length ? `${head}: ${lines.join(' · ')}` : head } const fallback = t('common.error', { defaultValue: 'Algo salió mal. Intenta de nuevo.' }) const ex = extractServerError(err, fallback) const title = t(ex.title, { defaultValue: ex.title }) return ex.description && ex.description !== title ? `${title} — ${ex.description}` : title } function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) { const { t, i18n } = useTranslation() const api = useApi() const [executing, setExecuting] = useState(false) const [formError, setFormError] = useState() const [stamp, setStamp] = useState() // Actions listed in the model's `reason_required.actions` (cancel, void…) // answer 422 errors.reason without a motive; the prompt asks and retries. const reasonPrompt = useReasonPrompt() const supervisor = useSupervisor() // `action.label` is an addon-contributed i18n key; its locale bundle loads // asynchronously, so translate at render (defaultValue keeps an already // localized label unchanged). const label = t(action.label, { defaultValue: action.label }) const execute = async () => { setExecuting(true) setFormError(undefined) try { const url = buildActionUrl(endpoint, model, record.id, action.key) // Supervisor PIN first (action.supervisorPolicy); null = cancelled. const auth = await supervisor.authorize({ label, supervisorPolicy: action.supervisorPolicy }, { model, recordId: String(record.id) }) if (!auth) return const res = await reasonPrompt.run({ title: label, description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }), request: (reason) => api.post(url, withApproval(reason ? { reason } : {}, auth)), }) // Operator cancelled the reason prompt: nothing ran. if (!res) return if (res.data.success) { toastServerSuccess(res.data, { t }) const result = extractStampResult(res.data) if (result) { setStamp(result) return } onOpenChange(false) onSuccess() } else { setFormError(actionErrorBannerMessage({ response: { data: res.data } }, action.fields, t, i18n.language)) toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language) } } catch (err: any) { setFormError(actionErrorBannerMessage(err, action.fields, t, i18n.language)) toastActionError(err, action.fields, t, i18n.language) } finally { setExecuting(false) } } if (stamp) { return ( { if (o) return setStamp(undefined) onOpenChange(false) onSuccess() }} /> ) } return ( <> {reasonPrompt.dialog} {label} {action.confirmMessage || `${label}?`} {record ? : null} {t('common.cancel')} { e.preventDefault(); execute() }} disabled={executing} style={action.color ? { backgroundColor: action.color } : undefined} > {executing ? : } {label} ) } function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) { const { t, i18n } = useTranslation() // Addon-contributed labels (action + fields) are i18n keys whose locale // bundle loads asynchronously; translate at render so they don't render raw. // defaultValue keeps an already-localized string unchanged. const tl = (s: string) => t(s, { defaultValue: s }) const api = useApi() const reasonPrompt = useReasonPrompt() const supervisor = useSupervisor() const branchGate = useBranchCreateGate() const orgTimeZone = useTimeZone() const [formData, setFormData] = useState>({}) const [executing, setExecuting] = useState(false) // Per-field validation errors (localized), shown inline under each input. const [fieldErrors, setFieldErrors] = useState>({}) // Persistent in-dialog banner for a failed submit (422 / 500) — the toast // alone vanished and, for some failures, only the console had the cause. const [formError, setFormError] = useState() const [stamp, setStamp] = useState() // Related records to surface BELOW the form, as read-only context for the // record being acted on — e.g. the reception history of a transfer while // receiving against it. Sourced from the model's metadata.relations (the // same declarative relations the detail view renders). Only row actions on a // real record show them; create actions (no record.id) render nothing. const [relations, setRelations] = useState([]) const recordId = record?.id useEffect(() => { let cancelled = false if (!open || recordId == null) { setRelations([]) return } api.get(`/metadata/table/${model}`) .then((res) => { if (cancelled) return const rels = res?.data?.relations ?? res?.data?.data?.relations ?? [] setRelations(Array.isArray(rels) ? rels : []) }) .catch(() => { if (!cancelled) setRelations([]) }) return () => { cancelled = true } }, [open, model, recordId, api]) useEffect(() => { if (open && action.fields) { setFormData(buildFieldDefaults(action.fields, record, model, { timeZone: orgTimeZone })) setFieldErrors({}) setFormError(undefined) } }, [open, action.fields, record, model, orgTimeZone]) // Lines the list row does not carry: load them from the declared relation // (see prefillRelationRequests) and seed the line-items grid. useEffect(() => { if (!open || relations.length === 0) return const reqs = prefillRelationRequests(action.fields, record, relations) if (reqs.length === 0) return let cancelled = false for (const req of reqs) { api.get(req.endpoint, { params: req.params }) .then((res: any) => { if (cancelled) return const rows = res?.data?.data ?? res?.data ?? [] if (!Array.isArray(rows)) return const built = buildPrefillRows(req.spec, { [req.spec.$prefillFromRecord]: rows }) setFormData((prev: Record) => ({ ...prev, [req.fieldKey]: built })) }) .catch(() => {}) } return () => { cancelled = true } }, [open, action.fields, record, relations, api]) // Lines seeded from a document picked in a sibling field (PrefillSpec.fromField). // Re-runs only when the picked id changes; each pick loads the referenced // model's relations (cached per model for the life of the modal) and then // the child rows. const fromFieldReqs = useMemo( () => prefillFromFieldRequests(action.fields, formData), [action.fields, formData], ) const fromFieldSig = fromFieldReqs.map((r) => `${r.fieldKey}=${r.refModel}:${r.refId}`).join('|') const refRelationsCache = useRef>>(new Map()) useEffect(() => { if (!open || fromFieldReqs.length === 0) return let cancelled = false for (const req of fromFieldReqs) { if (!req.refId) { setFormData((prev: Record) => Array.isArray(prev[req.fieldKey]) && prev[req.fieldKey].length === 0 ? prev : { ...prev, [req.fieldKey]: [] }, ) continue } let relsPromise = refRelationsCache.current.get(req.refModel) if (!relsPromise) { relsPromise = api .get(`/metadata/table/${req.refModel}`) .then((res: any) => { const rels = res?.data?.relations ?? res?.data?.data?.relations ?? [] return Array.isArray(rels) ? rels : [] }) .catch(() => [] as any[]) refRelationsCache.current.set(req.refModel, relsPromise) } relsPromise .then((rels) => { const call = prefillFromFieldRelationRequest(req, rels) if (!call) return return api.get(call.endpoint, { params: call.params }).then((res: any) => { if (cancelled) return const rows = res?.data?.data ?? res?.data ?? [] if (!Array.isArray(rows)) return const built = buildPrefillRows(req.spec, { [req.spec.$prefillFromRecord]: rows }) setFormData((prev: Record) => ({ ...prev, [req.fieldKey]: built })) }) }) .catch(() => {}) } return () => { cancelled = true } // fromFieldSig captures every dependency of fromFieldReqs that matters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, fromFieldSig, api]) const updateField = (key: string, value: any) => { setFormData((prev: Record) => ({ ...prev, [key]: value })) setFieldErrors((prev) => clearFieldErrorTree(prev, key)) } const lang = i18n.language const handleActionError = (err: unknown) => { setFormError(actionErrorBannerMessage(err, action.fields, t, lang)) const labels = labelsForValidationFields(action.fields, t) const localized = localizeActionFieldErrors(err, action.fields, t, lang) if (localized) { // Enrich labels for dotted line-item paths before toasting. const withPathLabels: Record = {} for (const [path, msg] of Object.entries(localized)) { withPathLabels[path] = msg if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t) } setFieldErrors(withPathLabels) toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), { description: formatFieldErrorsDescription(withPathLabels, action.fields, t), }) return } toastServerError(err, { t, language: lang, labels }) } const execute = async () => { if (action.fields) { const bag = validateValues(action.fields, formData) if (bagHasErrors(bag)) { const labels = labelsForValidationFields(action.fields, (k, o) => t(k, o)) // Exact dotted keys (`lines.0.qty`) need path-aware labels. for (const path of Object.keys(bag)) { if (!labels[path]) labels[path] = labelForValidationPath(path, action.fields, t) } const next = localizeFieldErrorMap(bag, t, { labels, language: lang }) setFieldErrors(next) toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), { description: formatFieldErrorsDescription(next, action.fields, t), }) return } } setFieldErrors({}) setFormError(undefined) setExecuting(true) try { let payload: Record = { ...formData } // Create-placement actions (and any create with no record) may need a // host branch stamp when the sidebar is on "Todas". Skip when the // form already collected branch_id, or the host has no gate. const isCreatePlacement = action.placement === 'create' || record?.id == null || record?.id === '' || record?.id === 'undefined' if ( branchGate && isCreatePlacement && (payload.branch_id == null || payload.branch_id === '') ) { const branchId = await branchGate.ensureBranchForCreate(model) if (branchId === null) return if (branchId) payload = { ...payload, branch_id: branchId } } const url = buildActionUrl(endpoint, model, record?.id, action.key) const auth = await supervisor.authorize( { label: tl(action.label), supervisorPolicy: action.supervisorPolicy }, { model, recordId: record?.id != null ? String(record.id) : undefined }, ) if (!auth) return const res = await reasonPrompt.run({ title: tl(action.label), description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }), request: (reason) => api.post(url, withApproval(reason ? { ...payload, reason } : payload, auth)), }) if (!res) return if (res.data.success) { toastServerSuccess(res.data, { t }) const result = extractStampResult(res.data) if (result) { setStamp(result) return } onOpenChange(false) onSuccess() } else { handleActionError({ response: { data: res.data } }) } } catch (err: any) { handleActionError(err) } finally { setExecuting(false) } } // Size the modal to the form. A line-items form (the debit/credit grid of a // journal entry, a "receive goods" modal) needs room for its columns, so it // gets a roomy width; a plain field form stays compact. An explicit // `action.modalWidth` (number px or CSS length) overrides — the declarative // escape hatch. Width is applied as an inline style (guaranteed to apply, // unlike an arbitrary Tailwind class that the host's scan may drop), capped // to the viewport so it stays responsive on phones. const hasLineItems = useMemo( () => (action.fields ?? []).some(isLineItemsField), [action.fields], ) const embedRelations = hasLineItems || !!(action as ActionMetadata & { embedRelations?: boolean }).embedRelations const explicitWidth = (action as unknown as { modalWidth?: number | string }).modalWidth const widthPx = explicitWidth != null ? (typeof explicitWidth === 'number' ? `${explicitWidth}px` : explicitWidth) : hasLineItems ? '820px' : undefined if (stamp) { return ( { if (o) return setStamp(undefined) onOpenChange(false) onSuccess() }} /> ) } return ( {/* Sticky header + footer, scrollable body: the form can grow tall (many line-items rows) past the viewport, so cap the dialog at 90dvh and let ONLY the field area scroll — the title and the Cancel/Submit actions stay pinned and always reachable. maxHeight is inline (guaranteed) since an arbitrary max-h-[90dvh] class may be dropped by a consuming app's Tailwind scan. */} {tl(action.label)} {action.confirmMessage && {tl(action.confirmMessage)}} {/* Scrollable body. The shared FieldGrid lays scalar fields out in two responsive columns (single column on phones); line-items grids and textareas span the full width. `min-w-0` on each cell (in FieldCell) keeps a long select/input value from blowing the grid past the dialog and spawning a horizontal scrollbar. */}
{action.fields?.map((field) => { const fullWidth = isLineItemsField(field) || resolveWidget(field) === 'textarea' || resolveWidget(field) === 'richtext' return ( {tl(field.label)} {renderField( field, formData[field.key], (v: any) => updateField(field.key, v), formData, record, fieldErrors, )} {fieldErrors[field.key] && (

{fieldErrors[field.key]}

)}
) })} {embedRelations && relations.length > 0 && ( {/* Igual que el modal de registro: solo las relaciones de composición se embeben. */} )}
{reasonPrompt.dialog}
) } /** Context a declared field default is resolved against. */ export interface FieldDefaultContext { /** Org IANA timezone: `$today` is the org's day, not the browser's or UTC's. */ timeZone?: string /** Clock (tests). */ now?: Date } const DATE_FIELD_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz']) /** * The declared scalar default of a field, resolved: `defaultValue` (host * camelCase) or `default` (raw manifest, the kernel serves action/document-form * fields without renaming it). A date field may declare `$today` / `today` and * gets the org's calendar day. An unknown `$token` never lands in a date field * (undefined): a literal "$today" posted as invoice_date is a 422, an empty * field is just a required field to fill. */ export function resolveFieldDefault(field: ActionFieldDef, ctx: FieldDefaultContext = {}): unknown { const f = field as { defaultValue?: unknown; default?: unknown } const raw = f.defaultValue ?? f.default if (raw === undefined || raw === null) return undefined if (DATE_FIELD_TYPES.has(String(field.type ?? '').toLowerCase()) && typeof raw === 'string') { if (isTodayToken(raw)) return todayInZone(ctx.timeZone, ctx.now) if (raw.trim().startsWith('$')) return undefined } return raw } // buildFieldDefaults seeds formData for a set of action fields, honoring the // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so // wizard steps and single-page forms initialize identically. export function buildFieldDefaults( fields: ActionFieldDef[], record: any, model?: string, ctx: FieldDefaultContext = {}, ): Record { const defaults: Record = {} for (const field of fields) { if (isLineItemsField(field)) { const dv = lineItemsDefault(field) defaults[field.key] = isPrefillSpec(dv) ? buildPrefillRows(dv, record) : Array.isArray(dv) ? dv : [] continue } const fromRecord = scalarDefaultFromRecord(field, record, model) if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') { defaults[field.key] = fromRecord continue } defaults[field.key] = resolveFieldDefault(field, ctx) ?? (field.type === 'boolean' ? false : '') } return defaults } function wizardStepErrors( fields: ActionFieldDef[], formData: Record, t: Translate, language?: string, ): Record | undefined { const bag = validateValues(fields, formData) if (!bagHasErrors(bag)) return undefined const labels: Record = {} for (const f of fields) labels[f.key] = t(f.label, { defaultValue: f.label }) return localizeFieldErrorMap(bag, t, { labels, language }) } // WizardActionModal — the third render-path: a multi-step form. It accumulates // every step's fields into ONE formData object and, on the final step, POSTs all // of them to the same endpoint GenericActionModal uses (buildActionUrl). Each // step is validated before "Siguiente" advances; a progress/step bar shows where // the user is. Widgets are rendered by the same renderField/resolveWidget path, // so line-items, dynamic_select, uploads and dates behave identically to a // single-page action form — no widget is duplicated. function WizardActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) { const { t, i18n } = useTranslation() const tl = (s: string) => t(s, { defaultValue: s }) const api = useApi() const reasonPrompt = useReasonPrompt() const supervisor = useSupervisor() const orgTimeZone = useTimeZone() const steps = action.steps ?? [] const [stepIndex, setStepIndex] = useState(0) const [formData, setFormData] = useState>({}) const [executing, setExecuting] = useState(false) const [formError, setFormError] = useState() const [stamp, setStamp] = useState() // Reset to the first step and seed defaults for EVERY step's fields whenever // the modal (re)opens, so accumulated values from a prior run don't leak. useEffect(() => { if (!open) return const allFields = steps.flatMap((s) => s.fields ?? []) setFormData(buildFieldDefaults(allFields, record, model, { timeZone: orgTimeZone })) setStepIndex(0) setFormError(undefined) }, [open, action.steps, record, model, orgTimeZone]) const updateField = (key: string, value: any) => setFormData((prev: Record) => ({ ...prev, [key]: value })) const step = steps[stepIndex] const isLast = stepIndex === steps.length - 1 const stepFields = step?.fields ?? [] const hasLineItems = useMemo( () => stepFields.some(isLineItemsField), [stepFields], ) const widthPx = hasLineItems ? '820px' : undefined const goNext = () => { const localized = wizardStepErrors(stepFields, formData, t, i18n.language) if (localized) { toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), { description: Object.values(localized).join('\n'), }) return } setStepIndex((i) => Math.min(i + 1, steps.length - 1)) } const goBack = () => setStepIndex((i) => Math.max(i - 1, 0)) const submit = async () => { // Guard every step's required fields on final submit (a user could reach // the last step with an untouched earlier line-items grid otherwise). for (const s of steps) { const localized = wizardStepErrors(s.fields ?? [], formData, t, i18n.language) if (localized) { toast.error(t('validation.failed', { defaultValue: validationCatalog(i18n.language).failed }), { description: Object.values(localized).join('\n'), }) return } } setExecuting(true) setFormError(undefined) try { const url = buildActionUrl(endpoint, model, record?.id, action.key) const auth = await supervisor.authorize( { label: tl(action.label), supervisorPolicy: action.supervisorPolicy }, { model, recordId: record?.id != null ? String(record.id) : undefined }, ) if (!auth) return const res = await reasonPrompt.run({ title: tl(action.label), description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }), request: (reason) => api.post(url, withApproval(reason ? { ...formData, reason } : formData, auth)), }) if (!res) return const allFields = steps.flatMap(s => s.fields ?? []) if (res.data.success) { toastServerSuccess(res.data, { t }) const result = extractStampResult(res.data) if (result) { setStamp(result) return } onOpenChange(false) onSuccess() } else { setFormError(actionErrorBannerMessage({ response: { data: res.data } }, allFields, t, i18n.language)) toastActionError({ response: { data: res.data } }, allFields, t, i18n.language) } } catch (err: any) { setFormError(actionErrorBannerMessage(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)) toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language) } finally { setExecuting(false) } } if (steps.length === 0) return null if (stamp) { return ( { if (o) return setStamp(undefined) onOpenChange(false) onSuccess() }} /> ) } return ( {tl(action.label)} {/* Step indicator: the platform ProcessStepper (same as the declarative form_layout wizards and the process modals) + "Paso i/n · título" and the step's description. */}
({ key: String(i), label: s.title ? tl(s.title) : `${t('common.step', { defaultValue: 'Paso' })} ${i + 1}` }))} activeIndex={stepIndex} onStepClick={i => setStepIndex(i)} /> {t('common.step', { defaultValue: 'Paso' })} {stepIndex + 1}/{steps.length} {step?.title ? ` · ${tl(step.title)}` : ''} {step?.description && (

{tl(step.description)}

)}
{/* Body: only the current step's fields, laid out on the same shared FieldGrid the single-page form uses. Every step's values persist in the one formData object, so navigating back and forth keeps entries intact. */}
{stepFields.map((field) => { const fullWidth = isLineItemsField(field) || resolveWidget(field) === 'textarea' || resolveWidget(field) === 'richtext' return ( {tl(field.label)} {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData, record)} ) })}
{/* Back is available from the second step on; Cancel closes. */} {stepIndex > 0 ? ( ) : ( )} {isLast ? ( ) : ( )} {reasonPrompt.dialog}
) } /** Columnas con las que se nombra una fila cuando el selector no declara `options.label`. */ const RECORD_LABEL_COLUMNS = ['number', 'folio', 'name', 'title', 'code', 'label'] /** * La etiqueta de la PROPIA fila cuando el campo se sembró con su id * («Factura a abonar» al registrar un pago desde la factura): la columna que el * selector usa de etiqueta (`options.label`) o la primera de nombre que tenga. * Sin esto el selector mostraba el UUID crudo (retest Pitsline r5). */ function selfLabel(field: ActionFieldDef, record: Record): string { const opts = (field as { options?: unknown }).options const declared = opts && !Array.isArray(opts) && typeof opts === 'object' ? (opts as { label?: unknown }).label : undefined const cols = typeof declared === 'string' && declared ? [declared, ...RECORD_LABEL_COLUMNS] : RECORD_LABEL_COLUMNS for (const c of cols) { const v = unwrapRecordScalar(record[c]) if ((typeof v === 'string' && v.trim()) || typeof v === 'number') return String(v) } return '' } export function seedOptionFromRecord( field: ActionFieldDef, value: any, record?: Record, ): import('./use-options-resolver').ResolvedOption | undefined { if (!record) return undefined const selfId = unwrapRecordScalar(record.id) if (value != null && value !== '' && selfId != null && String(selfId) === String(value)) { const label = selfLabel(field, record) if (label) return { id: String(value), value: String(value), label, name: label } } if (!field.key.endsWith('_id')) return undefined const siblingKey = field.key.replace(/_id$/, '') const sib = record[siblingKey] if (!sib || typeof sib !== 'object') return undefined const label = (sib as any).label ?? (sib as any).name ?? '' if (!label && !(sib as any).image) return undefined const id = String((sib as any).value ?? (sib as any).id ?? value ?? '') return { id, value: id, label: String(label), name: String(label), image: (sib as any).image, color: (sib as any).color, icon: (sib as any).icon, } } /** Renders one action field (same widgets as the action modals). Shared with DocumentFormDialog. */ export function renderField( field: ActionFieldDef, value: any, onChange: (value: any) => void, // Full current form values — lets a line-items grid (and any cascading // header picker) resolve a `dependsOn` reference against sibling header // fields. Omitted by callers that have no surrounding form (the field is // then treated as having no resolvable dependency). formValues?: Record, record?: Record, fieldErrors?: Record, ) { // Repeatable line-items group → row grid (value is an array of row objects). // The header form values flow in so a cell can depend on a header field. if (isLineItemsField(field)) { return ( ) } // Resolve the widget the same way DynamicForm does (explicit widget wins, // else inferred from type) so action modals and the standalone form stay in // lockstep — previously this switch keyed off `field.type` and silently // dropped `dynamic_select` to a plain text input. const widget = resolveWidget(field) const invalid = !!(fieldErrors && fieldErrors[field.key]) const invalidCls = invalid ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive' : '' if (widget === 'dynamic_select') { // A header-level dynamic_select may itself depend on another header // field; resolve its filter_value from the form context. const dependsValue = getDependsOn(field) ? resolveDependsValue(field, formValues) : undefined return ( ) } // File upload → themed picker that POSTs the file to the host upload // endpoint and stores the returned url/path. Kept in sync with DynamicForm. if (widget === 'upload') { return } switch (widget) { case 'textarea': return