// DocumentFormDialog — alta guiada por tipo de documento (FAC-12, #1022). // // "Crear documento fiscal" era el mismo formulario genérico para factura, nota // de crédito, REP, global y traslado: los campos los dictaba el modelo, no el // tipo. Aquí el metadata (`document_forms`) o una prop declara los tipos; el // diálogo muestra tarjetas de tipo y, al elegir uno, SUS campos y (si lo declara) // el paso de renglones con el editor de renglones del SDK. Un solo tipo se salta // el selector; sin manifest el alta genérica sigue igual (DynamicCRUDPage decide). // // El SDK no hardcodea catálogos SAT: campos, valores fijos y el valor del tipo // vienen del manifest que sirve el kernel o el addon. import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@asteby/metacore-ui/primitives' import { Loader2 } from 'lucide-react' import { toast } from 'sonner' import { useApi } from './api-context' import { useOrgTaxRate, useTimeZone } from './org-runtime-context' import { DynamicIcon } from './dynamic-icon' import { FieldCell, FieldGrid, FieldLabel } from './field-grid' import { FormErrorBanner } from './business/feedback' import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn, } from './business/line-items-editor' import { serializeLineItems, validateLineItems, type LineItem } from './business/line-items' import { actionErrorBannerMessage, buildFieldDefaults, renderField, } from './action-modal-dispatcher' import { extractFieldErrors, localizeFieldErrorMap } from './server-error' import { validateValues, bagHasErrors } from './validator' import { clearFieldErrorTree } from './field-validation-ui' import { emitRecordMutation } from './record-mutation-events' import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema' import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search' import { DocumentEditor } from './business/document-editor' import { editorLinesConfig, isEditorLayout, isFullscreenEditor } from './business/document-editor-model' import type { ActionFieldDef, DocumentFormType, DocumentFormsManifest, TableMetadata, } from './types' export interface DocumentFormDialogProps { open: boolean onOpenChange: (open: boolean) => void /** Modelo en el que se da de alta (p. ej. `fiscal_documents`). */ model: string /** Endpoint de alta. Default `/dynamic/`. */ endpoint?: string forms: DocumentFormsManifest /** Tipo preseleccionado (se salta el selector). */ initialType?: string /** Se llama con el registro creado. */ onSaved?: (record?: unknown) => void /** * Buscador de productos para el editor de renglones (mismo contrato que * ProductPicker). Sin él se busca en el catálogo (`productModel`) vía /data. */ searchProducts?: DocumentLinesGridProps['search'] /** Modelo de catálogo del buscador por defecto. Default `products.Product`. */ productModel?: string /** Tasa de impuesto (0.16) si el producto no trae la suya. Default: la de la org (OrgRuntimeProvider). */ defaultTaxRate?: number /** Moneda ISO para el editor de renglones (default: la de la org). */ currency?: string /** * Registro desde el que se abre (acción de fila «Crear NC», «Registrar * cobro»…): siembra los campos con `default_from_record`. */ record?: Record /** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */ initialSource?: { key: string; id: string } /** * Tipo con `create_model`: el alta vive en otro modelo. El host navega a la * página de ese modelo con su alta abierta; el diálogo se cierra sin pintar * formulario. Sin este callback un tipo delegado no se ofrece. */ onDelegateCreate?: (target: DelegatedCreate) => void } /** Alta delegada a otro modelo (`document_forms.types[].create_model`). */ export interface DelegatedCreate { /** Clave del modelo destino tal como la declara el manifest (`customers.Invoice`). */ model: string /** Tipo que la declaró. */ type: DocumentFormType } /** * Si «Crear» de estos formularios es un alta delegada: un único tipo (p. ej. * ya acotado por el filtro de la vista) con `create_model`. Con varios tipos el * selector la ofrece como una tarjeta más. */ export function delegatedCreate(forms: DocumentFormsManifest | undefined): DelegatedCreate | undefined { const only = forms?.types.length === 1 ? forms.types[0] : undefined const model = only?.create_model?.trim() return only && model ? { model, type: only } : undefined } /** Quita los tipos delegados cuando el host no sabe navegar a otro modelo. */ export function withoutDelegatedTypes(forms: DocumentFormsManifest | undefined): DocumentFormsManifest | undefined { if (!forms) return forms const types = forms.types.filter((x) => !x.create_model?.trim()) if (types.length === forms.types.length) return forms return types.length > 0 ? { ...forms, types } : undefined } /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */ export function resolveDocumentForms( metadata?: Pick | null, override?: DocumentFormsManifest | null, ): DocumentFormsManifest | undefined { const m = override ?? metadata?.document_forms return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined } /** * Acota el alta guiada a la vista filtrada. Un nav que filtra por la columna * discriminadora (`type_field`) lista UN tipo de documento: «Crear» debe abrir * ese tipo directo y, si el manifest no trae formulario para él, no ofrecerse * (retest r3: «Documentos fiscales → Facturas → Crear» abría el REP, el único * tipo declarado). Sin manifest, sin `type_field` o sin filtro sobre él, todo * queda igual. */ export function scopeDocumentFormsToFilter( forms: DocumentFormsManifest | undefined, filter?: Record | null, ): { forms: DocumentFormsManifest | undefined; hideCreate: boolean } { const field = forms?.type_field if (!forms || !field || !filter || !(field in filter)) return { forms, hideCreate: false } const want = scopeValueFromFilterToken(filter[field]).trim().toLowerCase() if (want === '') return { forms, hideCreate: false } const types = forms.types.filter((x) => String(x.value ?? x.key).trim().toLowerCase() === want) if (types.length === 0) return { forms: undefined, hideCreate: true } return { forms: types.length === forms.types.length ? forms : { ...forms, types }, hideCreate: false } } type Step = 'type' | 'fields' | 'lines' export function DocumentFormDialog({ open, onOpenChange, model, endpoint, forms, initialType, onSaved, searchProducts, productModel, defaultTaxRate, currency, record, initialSource, onDelegateCreate, }: DocumentFormDialogProps) { const { t, i18n } = useTranslation() const api = useApi() const orgTaxRate = useOrgTaxRate() const orgTimeZone = useTimeZone() // Sin onDelegateCreate el host no sabe abrir otro modelo: sus tipos no se ofrecen. const types = useMemo( () => (onDelegateCreate ? forms.types : forms.types.filter((x) => !x.create_model?.trim())), [forms.types, onDelegateCreate], ) const single = types.length === 1 ? types[0] : undefined const [typeKey, setTypeKey] = useState(initialType ?? single?.key ?? null) const [step, setStep] = useState(initialType || single ? 'fields' : 'type') const [formData, setFormData] = useState>({}) const [lines, setLines] = useState([]) const [fieldErrors, setFieldErrors] = useState>({}) const [serverLineErrors, setServerLineErrors] = useState>({}) const [formError, setFormError] = useState() const [saving, setSaving] = useState(false) const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey]) const delegatedModel = type?.create_model?.trim() || undefined // Tipo con alta en otro modelo: no hay formulario aquí; el host navega. useEffect(() => { if (!open || !type || !delegatedModel) return onDelegateCreate?.({ model: delegatedModel, type }) onOpenChange(false) // onDelegateCreate/onOpenChange: callbacks del host, no disparan de nuevo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, type, delegatedModel]) // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre» // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts). // Default: el catálogo de productos de la org. El buscador del host también // recibe el IVA de la org para los productos que no traen su tasa. const taxRate = defaultTaxRate ?? orgTaxRate const lineSearch = useMemo( () => searchProducts ? withDefaultTaxRate(searchProducts, taxRate) : createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }), [searchProducts, api, productModel, taxRate], ) const lineCfg = type ? editorLinesConfig(type, forms) : undefined // Reset every time the dialog (re)opens — DURING render, not in an effect. // The host keeps this dialog mounted while closed (and across views whose // forms change, e.g. the same /m/$model route going Facturas → Notas de // crédito); an effect reset ran after the first open frame was already // painted with the previous type/step, so the wizard (or the previous // type's editor) flashed before the right DocumentEditor (QA Pitsline r6). const openKey = open ? `${initialType ?? ''}\u0000${single?.key ?? ''}` : null const [resetFor, setResetFor] = useState(null) if (openKey !== resetFor) { setResetFor(openKey) if (openKey !== null) { const start = initialType ?? single?.key ?? null setTypeKey(start) setStep(start ? 'fields' : 'type') setLines([]) setFieldErrors({}) setServerLineErrors({}) setFormError(undefined) } } // Seed field defaults for the chosen type. useEffect(() => { if (!type) return setFormData({ ...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone }), ...(type.defaults ?? {}), }) setFieldErrors({}) setFormError(undefined) }, [type, record, orgTimeZone]) const updateField = (key: string, value: any) => { setFormData((prev) => ({ ...prev, [key]: value })) setFieldErrors((prev) => clearFieldErrorTree(prev, key)) } const tl = (s: string) => t(s, { defaultValue: s }) const lang = i18n?.language const chooseType = (k: string) => { setTypeKey(k) setStep('fields') } /** Validate the header fields; returns true when they pass. */ const validateHeader = (): boolean => { if (!type) return false const bag = validateValues(type.fields, formData) if (!bagHasErrors(bag)) { setFieldErrors({}) return true } const labels: Record = {} for (const f of type.fields) labels[f.key] = tl(f.label) setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang })) return false } const goNext = () => { if (validateHeader()) setStep('lines') } const submit = async () => { if (!type) return if (!validateHeader()) { setStep('fields') return } if (lineCfg && (lineCfg.required ?? true)) { const v = validateLineItems(lines, { requireItems: true }) if (!v.form && Object.keys(v.errors).length === 0) { /* ok */ } else { setServerLineErrors(v.errors) setFormError(v.form ?? t('documentForm.lines_invalid', { defaultValue: 'Revisa los renglones del documento.' })) setStep('lines') return } } setSaving(true) setFormError(undefined) try { const payload: Record = { ...(type.defaults ?? {}), ...formData } if (forms.type_field) payload[forms.type_field] = type.value ?? type.key if (lineCfg) payload[lineCfg.field] = serializeLineItems(lines) const url = type.endpoint ?? endpoint ?? `/dynamic/${model}` const res = await api.post(url, payload) if (res.data?.success === false) { handleError({ response: { data: res.data } }) return } toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })) emitRecordMutation(model, 'create') onSaved?.(res.data?.data ?? res.data ?? undefined) onOpenChange(false) } catch (err) { handleError(err) } finally { setSaving(false) } } /** 422 → marks the fields and shows the banner INSIDE the dialog; stays open. */ const handleError = (err: unknown) => { setFormError(actionErrorBannerMessage(err, type?.fields, t, lang)) const map = extractFieldErrors(err) if (map) { const labels: Record = {} for (const f of type?.fields ?? []) labels[f.key] = tl(f.label) const localized = localizeFieldErrorMap(map, t, { labels, language: lang }) const header: Record = {} const lineErrs: Record = {} const prefix = lineCfg ? `${lineCfg.field}.` : null for (const [k, v] of Object.entries(localized)) { if (prefix && k.startsWith(prefix)) lineErrs[k.slice(prefix.length)] = v else header[k] = v } setFieldErrors(header) setServerLineErrors(lineErrs) } } const canGoBackToTypes = types.length > 1 && !initialType const stepLabel = (s: Step) => s === 'type' ? t('documentForm.step_type', { defaultValue: 'Tipo de documento' }) : s === 'fields' ? t('documentForm.step_data', { defaultValue: 'Datos' }) : tl(lineCfg?.title ?? 'Renglones') if (open && type && delegatedModel) return null // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él // sigue el wizard de siempre (retrocompatible). Un documento con renglones // (factura, cotización, pedido, OC) ocupa toda la pantalla; NC y cobro // siguen en el diálogo grande. if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) { const fullscreen = isFullscreenEditor(type, forms) return ( {tl(type.label)} {type.description && {tl(type.description)}} (canGoBackToTypes ? setStep('type') : onOpenChange(false))} onSaved={(rec) => { onSaved?.(rec) onOpenChange(false) }} /> ) } return ( {type ? tl(type.label) : t('documentForm.title', { defaultValue: 'Nuevo documento' })} {stepLabel(step)}
{step === 'type' && (
{types.map((x) => ( ))}
)} {step === 'fields' && type && ( {type.fields.map((field: ActionFieldDef) => { 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, undefined, fieldErrors, )} {fieldErrors[field.key] && (

{fieldErrors[field.key]}

)}
) })}
)} {step === 'lines' && type && lineCfg && ( { setLines(next) setServerLineErrors({}) }} columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']} priceSource={lineCfg.price_source ?? 'sale'} discountMode={lineCfg.discount_mode} serverErrors={serverLineErrors} search={lineSearch} currency={currency} /> )}
{step === 'type' ? ( ) : ( )} {step === 'fields' && type && lineCfg && ( )} {((step === 'fields' && type && !lineCfg) || step === 'lines') && ( )}
) }