import { TaxCatalogProvider } from "@erp/core"; import React from "react"; import { UseFormReturn } from "react-hook-form"; import { InvoiceItemCalcResult, calculateInvoiceHeaderAmounts, calculateInvoiceItemAmounts, } from "../../domain"; import { InvoiceFormData, InvoiceItemFormData } from "../../schemas"; export type UseInvoiceAutoRecalcParams = { currency_code: string; taxCatalog: TaxCatalogProvider; }; /** * Hook que recalcula automáticamente los totales de cada línea * y los totales generales de la factura cuando cambian los valores relevantes. * Adaptado a formulario con números planos (no DTOs). */ export function useInvoiceAutoRecalc( form: UseFormReturn, params: UseInvoiceAutoRecalcParams ) { const { watch, trigger, formState: { isDirty, isLoading, isSubmitting }, } = form; const { currency_code, taxCatalog } = params; // Cache de los totales de cada línea de factura const itemCache = React.useRef>>( new Map() ); // Cálculo de una línea (usa dominio puro) const calculateItemTotals = React.useCallback( (item: InvoiceItemFormData, header_discount_percentage: number) => { const sanitizeString = (v?: number | string) => v && !Number.isNaN(Number(v)) ? String(v) : "0"; return calculateInvoiceItemAmounts( { quantity: sanitizeString(item.quantity), unit_amount: sanitizeString(item.unit_amount), discount_percentage: sanitizeString(item.discount_percentage), header_discount_percentage: sanitizeString(header_discount_percentage), tax_codes: item.tax_codes, }, currency_code, taxCatalog ); }, [taxCatalog, currency_code] ); // Totales globales (usa funciones del dominio) const calculateInvoiceTotals = React.useCallback( (items: InvoiceItemFormData[], header_discount_percentage: number) => { const lines = items .filter((i) => !i.is_non_valued) .map((i) => { const itemTotals = calculateItemTotals(i, header_discount_percentage); return { subtotal_amount: itemTotals.subtotal_amount, discount_amount: itemTotals.discount_amount, header_discount_amount: itemTotals.header_discount_amount, taxable_amount: itemTotals.taxable_amount, taxes_amount: itemTotals.taxes_amount, total_amount: itemTotals.total_amount, taxes_summary: itemTotals.taxes_summary, }; }); return calculateInvoiceHeaderAmounts(lines, currency_code); }, [calculateItemTotals, currency_code] ); // Observamos el formulario esperando cualquier cambio React.useEffect(() => { if (!isDirty || isLoading || isSubmitting) return; const subscription = watch((formData, { name, type }) => { const items = (formData?.items || []) as InvoiceItemFormData[]; const header_discount_percentage = formData?.discount_percentage || 0; if (items.length === 0) return; // Detectar cambios en la cabecera if (name === "discount_percentage") { // Recalcular totales de factura const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage); // Estableer valores en cabecera setInvoiceTotals(form, invoiceTotals); // Forzar actualización de todas las líneas items.forEach((item, idx) => { const newTotals = calculateItemTotals(item, header_discount_percentage); itemCache.current.set(idx, newTotals); setInvoiceItemTotals(form, idx, newTotals); }); trigger([ "subtotal_amount", "discount_amount", "taxable_amount", "taxes_amount", "total_amount", ]); } // 2. Cambio puntual de una línea if (name?.startsWith("items.") && type === "change") { const index = Number(name.split(".")[1]); const field = name.split(".")[2]; if (["quantity", "unit_amount", "discount_percentage", "tax_codes"].includes(field)) { const item = items[index] as InvoiceItemFormData; const prevTotals = itemCache.current.get(index); const newTotals = calculateItemTotals(item, header_discount_percentage); // Si no hay cambios en los totales, no tocamos nada const itemHasChanges = !prevTotals || JSON.stringify(prevTotals) !== JSON.stringify(newTotals); if (!itemHasChanges) { return; } // El total de esta línea ha cambiado => actualizamos la cache itemCache.current.set(index, newTotals); // Actualizar los campos de esta línea setInvoiceItemTotals(form, index, newTotals); // Recalcular totales de factura const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage); // Estableer valores en cabecera setInvoiceTotals(form, invoiceTotals); trigger([ "items", "subtotal_amount", "discount_amount", "taxable_amount", "taxes_amount", "total_amount", ]); } } }); return () => subscription.unsubscribe(); }, [ form, watch, trigger, isDirty, isLoading, isSubmitting, calculateItemTotals, calculateInvoiceTotals, ]); } // Ayudante para rellenar los importes de una línea function setInvoiceItemTotals( form: UseFormReturn, index: number, newTotals: InvoiceItemCalcResult ) { const { setValue } = form; setValue(`items.${index}.subtotal_amount`, newTotals.subtotal_amount, { shouldDirty: true, shouldValidate: false, }); setValue(`items.${index}.discount_amount`, newTotals.discount_amount, { shouldDirty: true, shouldValidate: false, }); setValue(`items.${index}.taxable_amount`, newTotals.taxable_amount, { shouldDirty: true, shouldValidate: false, }); setValue(`items.${index}.taxes_amount`, newTotals.taxes_amount, { shouldDirty: true, shouldValidate: false, }); setValue(`items.${index}.total_amount`, newTotals.total_amount, { shouldDirty: true, shouldValidate: false, }); } // Ayudante para actualizar los importes de la cabecera function setInvoiceTotals( form: UseFormReturn, invoiceTotals: ReturnType ) { const { setValue } = form; setValue("subtotal_amount", invoiceTotals.subtotal_amount, { shouldDirty: true, shouldValidate: false, }); setValue("discount_amount", invoiceTotals.header_discount_amount, { shouldDirty: true, shouldValidate: false, }); setValue("taxable_amount", invoiceTotals.taxable_amount, { shouldDirty: true, shouldValidate: false, }); setValue("taxes_amount", invoiceTotals.taxes_amount, { shouldDirty: true, shouldValidate: false, }); setValue("total_amount", invoiceTotals.total_amount, { shouldDirty: true, shouldValidate: false, }); setValue( "taxes", invoiceTotals.taxes_summary.map((tax_item) => ({ tax_code: tax_item.code, tax_label: tax_item.name, taxable_amount: tax_item.taxable_amount, taxes_amount: tax_item.taxes_amount, })) ); }