// 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 → MissingCustomActionModal // (NEVER fall 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 { useState, useEffect, useMemo } 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, localizeFieldErrorMap } from './server-error' 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 { 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 } 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, getActionComponent, } from '@asteby/metacore-sdk' 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[] } 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 } // ---- 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 } /** Scalar seed for one action field from the row being acted on. */ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): 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 } return undefined } export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess, }: ActionModalProps) { const CustomComponent = useMemo( () => getActionComponent(model, action.key), [model, action.key], ) if (CustomComponent) { return ( ) } // Declarative custom slot (`modal: "addon.action"`). Prefer a hard error // over confirm/fields generics — those look "fine" and hide a missing remote. if (action.modal) { 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 } /** 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 }) } function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) { const { t, i18n } = useTranslation() const api = useApi() const [executing, setExecuting] = useState(false) // `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) try { const url = buildActionUrl(endpoint, model, record.id, action.key) const res = await api.post(url, {}) if (res.data.success) { toastServerSuccess(res.data, { t }) onOpenChange(false) onSuccess() } else { toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language) } } catch (err: any) { toastActionError(err, action.fields, t, i18n.language) } finally { setExecuting(false) } } return ( {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 branchGate = useBranchCreateGate() const [formData, setFormData] = useState>({}) const [executing, setExecuting] = useState(false) // Per-field validation errors (localized), shown inline under each input. const [fieldErrors, setFieldErrors] = 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)) setFieldErrors({}) } }, [open, action.fields, record]) 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) => { 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({}) 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 res = await api.post(url, payload) if (res.data.success) { toastServerSuccess(res.data, { t }) 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 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. */} )}
) } // 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. function buildFieldDefaults(fields: ActionFieldDef[], record: any): 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) if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') { defaults[field.key] = fromRecord continue } defaults[field.key] = field.defaultValue ?? (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 steps = action.steps ?? [] const [stepIndex, setStepIndex] = useState(0) const [formData, setFormData] = useState>({}) const [executing, setExecuting] = useState(false) // 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)) setStepIndex(0) }, [open, action.steps, record]) 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) try { const url = buildActionUrl(endpoint, model, record?.id, action.key) const res = await api.post(url, formData) if (res.data.success) { toastServerSuccess(res.data, { t }) onOpenChange(false) onSuccess() } else { toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t, i18n.language) } } catch (err: any) { toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language) } finally { setExecuting(false) } } if (steps.length === 0) return null 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 ? ( ) : ( )}
) } function seedOptionFromRecord( field: ActionFieldDef, value: any, record?: Record, ): import('./use-options-resolver').ResolvedOption | undefined { if (!record || !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, } } 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