import { TaxCatalogProvider } from "@erp/core"; import React from "react"; import { UseFormReturn, useWatch } from "react-hook-form"; import { calculateInvoiceHeaderAmounts, calculateInvoiceItemAmounts, InvoiceItemCalcResult, } 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). * Evita renders innecesarios (debounce + useDeferredValue). */ export function useInvoiceAutoRecalc( form: UseFormReturn, { currency_code, taxCatalog }: UseInvoiceAutoRecalcParams ) { const { setValue, trigger, control } = form; // Observa los ítems y el descuento global const watchedItems = useWatch({ control, name: "items" }) ?? []; const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0; // Diferir valores pesados para reducir renders (React 19) const deferredItems = React.useDeferredValue(watchedItems); const deferredDiscount = React.useDeferredValue(watchedDiscount); // Cache para evitar recálculos redundantes const itemCache = React.useRef>(new Map()); // Debounce para agrupar recalculados rápidos const debounceTimer = React.useRef | null>(null); // Cálculo de una línea individual const calculateItemTotals = React.useCallback( (item: InvoiceItemFormData, header_discount_percentage: number) => { const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0"); return calculateInvoiceItemAmounts( { quantity: sanitize(item.quantity), unit_amount: sanitize(item.unit_amount), discount_percentage: sanitize(item.discount_percentage), header_discount_percentage: sanitize(header_discount_percentage), tax_codes: item.tax_codes, }, currency_code, taxCatalog ); }, [taxCatalog, currency_code] ); // Cálculo global de factura const calculateInvoiceTotals = React.useCallback( (items: InvoiceItemFormData[], header_discount_percentage: number) => { const lines = items .filter((i) => !i.is_non_valued) .map((i) => { const totals = calculateItemTotals(i, header_discount_percentage); return { subtotal_amount: totals.subtotal_amount, discount_amount: totals.discount_amount, header_discount_amount: totals.header_discount_amount, taxable_amount: totals.taxable_amount, taxes_amount: totals.taxes_amount, total_amount: totals.total_amount, taxes_summary: totals.taxes_summary, }; }); return calculateInvoiceHeaderAmounts(lines, currency_code); }, [calculateItemTotals, currency_code] ); // Observamos el formulario esperando cualquier cambio React.useEffect(() => { if (!deferredItems.length) return; if (debounceTimer.current) clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => { let shouldUpdateHeader = false; deferredItems.forEach((item, idx) => { const prev = itemCache.current.get(idx); const next = calculateItemTotals(item, deferredDiscount); if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { shouldUpdateHeader = true; itemCache.current.set(idx, next); setInvoiceItemTotals(form, idx, next); } }); if (shouldUpdateHeader) { const totals = calculateInvoiceTotals(deferredItems, deferredDiscount); setInvoiceTotals(form, totals); void trigger([ "subtotal_amount", "discount_amount", "taxable_amount", "taxes_amount", "total_amount", ]); } }, 100); // <-- debounce de 100ms, ajustable return () => { if (debounceTimer.current) clearTimeout(debounceTimer.current); }; }, [deferredItems, deferredDiscount, calculateItemTotals, calculateInvoiceTotals, form, trigger]); } // Ayudante para rellenar los importes de una línea function setInvoiceItemTotals( form: UseFormReturn, index: number, totals: InvoiceItemCalcResult ) { const { setValue } = form; const opts = { shouldDirty: true, shouldValidate: false } as const; setValue(`items.${index}.subtotal_amount`, totals.subtotal_amount, opts); setValue(`items.${index}.discount_amount`, totals.discount_amount, opts); setValue(`items.${index}.taxable_amount`, totals.taxable_amount, opts); setValue(`items.${index}.taxes_amount`, totals.taxes_amount, opts); setValue(`items.${index}.total_amount`, totals.total_amount, opts); } // Ayudante para actualizar los importes de la cabecera function setInvoiceTotals( form: UseFormReturn, totals: ReturnType ) { const { setValue } = form; const opts = { shouldDirty: true, shouldValidate: false } as const; setValue("subtotal_amount", totals.subtotal_amount, opts); setValue("discount_amount", totals.header_discount_amount, opts); setValue("taxable_amount", totals.taxable_amount, opts); setValue("taxes_amount", totals.taxes_amount, opts); setValue("total_amount", totals.total_amount, opts); setValue( "taxes", totals.taxes_summary.map((t) => ({ tax_code: t.code, tax_label: t.name, taxable_amount: t.taxable_amount, taxes_amount: t.taxes_amount, })), opts ); }