diff --git a/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts b/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts index 5b49cc6f..0694d258 100644 --- a/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts +++ b/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts @@ -1,10 +1,10 @@ import { TaxCatalogProvider } from "@erp/core"; import React from "react"; -import { UseFormReturn } from "react-hook-form"; +import { UseFormReturn, useWatch } from "react-hook-form"; import { - InvoiceItemCalcResult, calculateInvoiceHeaderAmounts, calculateInvoiceItemAmounts, + InvoiceItemCalcResult, } from "../../domain"; import { InvoiceFormData, InvoiceItemFormData } from "../../schemas"; @@ -17,36 +17,39 @@ export type UseInvoiceAutoRecalcParams = { * 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, - params: UseInvoiceAutoRecalcParams + { currency_code, taxCatalog }: UseInvoiceAutoRecalcParams ) { - const { - watch, - trigger, - formState: { isDirty, isLoading, isSubmitting }, - } = form; + const { setValue, trigger, control } = form; - const { currency_code, taxCatalog } = params; + // Observa los ítems y el descuento global + const watchedItems = useWatch({ control, name: "items" }) ?? []; + const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0; - // Cache de los totales de cada línea de factura - const itemCache = React.useRef>>( - new Map() - ); + // Diferir valores pesados para reducir renders (React 19) + const deferredItems = React.useDeferredValue(watchedItems); + const deferredDiscount = React.useDeferredValue(watchedDiscount); - // Cálculo de una línea (usa dominio puro) + // 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 sanitizeString = (v?: number | string) => - v && !Number.isNaN(Number(v)) ? String(v) : "0"; + const sanitize = (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), + 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, @@ -56,21 +59,21 @@ export function useInvoiceAutoRecalc( [taxCatalog, currency_code] ); - // Totales globales (usa funciones del dominio) + // 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 itemTotals = calculateItemTotals(i, header_discount_percentage); + const totals = 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, + 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, }; }); @@ -81,30 +84,29 @@ export function useInvoiceAutoRecalc( // Observamos el formulario esperando cualquier cambio React.useEffect(() => { - if (!isDirty || isLoading || isSubmitting) return; + if (!deferredItems.length) return; - const subscription = watch((formData, { name, type }) => { - const items = (formData?.items || []) as InvoiceItemFormData[]; - const header_discount_percentage = formData?.discount_percentage || 0; + if (debounceTimer.current) clearTimeout(debounceTimer.current); - if (items.length === 0) return; + debounceTimer.current = setTimeout(() => { + let shouldUpdateHeader = false; - // Detectar cambios en la cabecera - if (name === "discount_percentage") { - // Recalcular totales de factura - const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage); + deferredItems.forEach((item, idx) => { + const prev = itemCache.current.get(idx); + const next = calculateItemTotals(item, deferredDiscount); - // Estableer valores en cabecera - setInvoiceTotals(form, invoiceTotals); + if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { + shouldUpdateHeader = true; + itemCache.current.set(idx, next); + setInvoiceItemTotals(form, idx, next); + } + }); - // 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); - }); + if (shouldUpdateHeader) { + const totals = calculateInvoiceTotals(deferredItems, deferredDiscount); + setInvoiceTotals(form, totals); - trigger([ + void trigger([ "subtotal_amount", "discount_amount", "taxable_amount", @@ -112,127 +114,52 @@ export function useInvoiceAutoRecalc( "total_amount", ]); } + }, 100); // <-- debounce de 100ms, ajustable - // 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, - ]); + 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, - newTotals: InvoiceItemCalcResult + totals: InvoiceItemCalcResult ) { const { setValue } = form; + const opts = { shouldDirty: true, shouldValidate: false } as const; - 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, - }); + 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, - invoiceTotals: ReturnType + totals: ReturnType ) { const { setValue } = form; + const opts = { shouldDirty: true, shouldValidate: false } as const; - 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("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", - 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, - })) + totals.taxes_summary.map((t) => ({ + tax_code: t.code, + tax_label: t.name, + taxable_amount: t.taxable_amount, + taxes_amount: t.taxes_amount, + })), + opts ); }