// DynamicLineItems — renders a repeatable line-items group: a table/grid of // rows where each column is one of the field's `itemFields` (the v3 // `item_fields`). Powers declarative multi-line action modals (e.g. the item // rows of a "Recibir mercancía" modal, or the debit/credit lines of a journal // entry) without needing a custom federated modal. // // The value is an array of row objects keyed by the item field keys. Add/remove // row controls mutate the array; each cell is a widget resolved via // `resolveWidget`, matching the flat-field renderer in dynamic-form.tsx. import { Input, Textarea, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives' import { useEffect, useRef } from 'react' import { Plus, Trash2, Check } from 'lucide-react' import type { ActionFieldDef } from './types' import { resolveWidget, getItemFields, computeLineItemTotals, applyLineItemRowFormulas, evaluateBalance, toNumber, getDependsOn, resolveDependsValue, getOptionsConfig, resolveOptionsSource, applyOptionWhen, } from './dynamic-form-schema' import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field' import { useOptionsResolver, type ResolvedOption } from './use-options-resolver' export interface DynamicLineItemsProps { field: ActionFieldDef value: any[] | undefined onChange: (rows: any[]) => void disabled?: boolean /** * Current values of the surrounding (header) form. Threaded into each cell * so a cell field with `dependsOn` can scope its options by a HEADER field * (e.g. `source_warehouse_id`), not just a sibling cell on the same row. */ formValues?: Record /** * Localized validation messages keyed as `rowIndex.columnKey` (e.g. * `0.unit_price`). Painted as destructive borders + under-cell text. */ errors?: Record } const fmtNumber = (n: number): string => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) /** Numeric columns render right-aligned (debit/credit/amount feel). */ function isNumericCol(col: ActionFieldDef): boolean { return resolveWidget(col) === 'number' } function emptyRow(itemFields: ActionFieldDef[]): Record { const row: Record = {} for (const f of itemFields) { // Kernel serves `default` on action fields; the SDK type uses // `defaultValue` (host carryActionFieldDefaults). Accept both so a // manifest `"default": 0` on optional money cells (discount) seeds 0 // instead of "" — blank strings trip server parsers that treat a // present key as required. const seeded = f.defaultValue ?? (f as { default?: unknown }).default row[f.key] = seeded ?? (f.type === 'boolean' ? false : '') } return row } export function DynamicLineItems({ field, value, onChange, disabled = false, formValues, errors }: DynamicLineItemsProps) { const itemFields = getItemFields(field) const rows: any[] = Array.isArray(value) ? value : [] const errMap = errors ?? {} // `lock_rows` fixes the row set: no add-row button, no per-row delete. Rows // stay editable cell-by-cell. Snake_case is what the kernel serves; tolerate // the camelCase alias too. Derived once. const lockRows = field.lock_rows ?? (field as any).lockRows ?? false // Columns flagged `total` get a per-column sum in the footer; the balance // rule (if any) reconciles two of them. Both are declarative & generic. const totals = computeLineItemTotals(field, rows) const totalKeys = itemFields.filter((c) => c.total).map((c) => c.key) const hasTotals = totalKeys.length > 0 const balance = evaluateBalance(field, rows) const addRow = () => onChange([...rows, applyLineItemRowFormulas(itemFields, emptyRow(itemFields))]) const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx)) const updateCell = (idx: number, key: string, cellValue: any) => onChange( rows.map((r, i) => i === idx ? applyLineItemRowFormulas(itemFields, { ...r, [key]: cellValue }) : r, ), ) // When a balance rule reconciles two columns (e.g. debit ↔ credit), typing // into one clears the sibling on the same row — mirrors the federated modal // UX so a line is never both a debit and a credit. const balancePair: [string, string] | null = balance ? (() => { const f = getItemFields(field) void f const d = field.balance?.debitColumn ?? field.balance?.debit_column const c = field.balance?.creditColumn ?? field.balance?.credit_column return d && c ? [d, c] : null })() : null const handleCell = (idx: number, key: string, cellValue: any) => { if (balancePair && (key === balancePair[0] || key === balancePair[1])) { const sibling = key === balancePair[0] ? balancePair[1] : balancePair[0] const hasValue = toNumber(cellValue) > 0 onChange( rows.map((r, i) => i === idx ? applyLineItemRowFormulas(itemFields, { ...r, [key]: cellValue, ...(hasValue ? { [sibling]: '' } : {}), }) : r, ), ) return } updateCell(idx, key, cellValue) } const totalCols = itemFields.filter((c) => c.total) return (
{/* Móvil ( {rows.length === 0 && (
Sin renglones
)} {rows.map((row, idx) => (
Renglón {idx + 1} {!lockRows && ( )}
{itemFields.map((col) => { const cellErr = errMap[`${idx}.${col.key}`] return (
{col.label} {col.required && ( * )} handleCell(idx, col.key, v)} disabled={disabled} formValues={formValues} rowValues={row} invalid={!!cellErr} /> {cellErr && (

{cellErr}

)}
) })}
))} {hasTotals && rows.length > 0 && (
{totalCols.map((col) => (
{col.label} {fmtNumber(totals[col.key] ?? 0)}
))}
)}
{itemFields.map((col) => ( ))} {!lockRows && {rows.length === 0 && ( )} {rows.map((row, idx) => ( {itemFields.map((col) => { const cellErr = errMap[`${idx}.${col.key}`] return ( ) })} {!lockRows && ( )} ))} {hasTotals && rows.length > 0 && ( {itemFields.map((col, ci) => { if (ci === 0) { return ( ) } return ( ) })} {!lockRows && )}
{col.label} {col.required && *} }
Sin renglones
handleCell(idx, col.key, v)} disabled={disabled} formValues={formValues} rowValues={row} invalid={!!cellErr} /> {cellErr && (

{cellErr}

)}
Totales {col.total ? fmtNumber(totals[col.key] ?? 0) : null} }
{lockRows ? ( ) : ( )} {balance && }
) } function BalanceBadge({ state, }: { state: NonNullable> }) { if (state.balanced) { return ( Cuadrado ) } const diff = Math.abs(state.diff) return ( {state.message ?? `Descuadre: ${fmtNumber(diff)}`} ) } interface CellRendererProps { field: ActionFieldDef value: any onChange: (v: any) => void disabled?: boolean /** Header form values — for resolving a cell's `dependsOn` to a header field. */ formValues?: Record /** This row's values — for resolving a cell's `dependsOn` to a sibling cell. */ rowValues?: Record /** Paint the control as invalid (destructive border). */ invalid?: boolean } // Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but // without the per-field Label (the column header is the label) and sized for a // table cell. Nested line-items inside a row are not supported (a row column is // a scalar widget). function CellRenderer({ field, value, onChange, disabled, formValues, rowValues, invalid }: CellRendererProps) { const widget = resolveWidget(field) const invalidCls = invalid ? 'border-destructive ring-1 ring-destructive/30 focus-visible:ring-destructive' : '' // Per-field read-only: a column locked by a PrefillSpec.lock (e.g. the // "ordered" / "already received" progress columns of a receive-goods modal) // renders disabled so it shows context without being editable. Tolerates the // snake_case alias the kernel may serve. const ro = !!(field as { readonly?: boolean; read_only?: boolean }).readonly || !!(field as { readonly?: boolean; read_only?: boolean }).read_only const off = disabled || ro // Cascade scope for a cell with `dependsOn`: resolved from this row first // (a sibling cell) then the header form (e.g. `source_warehouse_id`). const dependsValue = getDependsOn(field) ? resolveDependsValue(field, formValues, rowValues) : undefined // STATIC enum options gated per-cell by a sibling (row) or header value. // Row values win over the header when a key exists in both, matching // `resolveDependsValue`. Filtered against each option's `when`. const isStaticSelect = widget === 'select' && !field.ref && !getOptionsConfig(field)?.source && Array.isArray(field.options) const gateValues = isStaticSelect ? { ...(formValues ?? {}), ...(rowValues ?? {}) } : undefined const effectiveOptions = isStaticSelect ? applyOptionWhen(field.options, gateValues, getDependsOn(field)) : undefined // Reset a selection the current sibling value no longer permits. useEffect(() => { if (!isStaticSelect || !effectiveOptions) return if (value && !effectiveOptions.some((o) => String(o.value) === String(value))) { onChange('') } }, [isStaticSelect, effectiveOptions, value, onChange]) // Async searchable picker per row cell — e.g. the account_id column of a // journal entry's debit/credit lines. Same widget as the flat form. if (widget === 'dynamic_select') { return ( ) } if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) { return ( ) } switch (widget) { case 'textarea': case 'richtext': return (