FactuGES_ERP/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts

191 lines
6.3 KiB
TypeScript

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;
debug?: boolean;
};
/**
* 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<InvoiceFormData>,
{ currency_code, taxCatalog, debug = true }: UseInvoiceAutoRecalcParams
) {
const { trigger, control } = form;
// Observa los ítems y el descuento global
const watchedItems = useWatch({ control, name: "items" }) ?? [];
const watchedDiscount = useWatch({ control, name: "discount_percentage", defaultValue: 0 }); // <- descuento global
// 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 [prevDiscount, setPrevDiscount] = React.useState(watchedDiscount);
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
// Debounce para agrupar recalculados rápidos
const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Cálculo de una línea individual
const calculateItemTotals = React.useCallback(
(item: InvoiceItemFormData) => {
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),
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_valued)
.map((i) => {
const totals = calculateItemTotals(i);
return {
subtotal_amount: totals.subtotal_amount,
discount_amount: totals.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, header_discount_percentage, 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;
if (prevDiscount !== deferredDiscount) {
shouldUpdateHeader = true;
setPrevDiscount(deferredDiscount);
}
deferredItems.forEach((item, idx) => {
const prev = itemCache.current.get(idx);
const next = calculateItemTotals(item);
const itemHasChanges =
!prev ||
prev.subtotal_amount !== next.subtotal_amount ||
prev.total_amount !== next.total_amount ||
prev.taxes_amount !== next.taxes_amount;
//if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { <-- Costoso y poco preciso
if (itemHasChanges) {
shouldUpdateHeader = true;
itemCache.current.set(idx, next);
setInvoiceItemTotals(form, idx, next);
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next.total_amount);
}
});
if (shouldUpdateHeader) {
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
setInvoiceTotals(form, totals);
if (debug) console.log("📊 Recalc invoice totals", totals.subtotal_amount);
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,
debug,
]);
}
// Ayudante para rellenar los importes de una línea
function setInvoiceItemTotals(
form: UseFormReturn<InvoiceFormData>,
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<InvoiceFormData>,
totals: ReturnType<typeof calculateInvoiceHeaderAmounts>
) {
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
console.log(totals);
setValue("subtotal_amount", totals.subtotal_amount, opts);
setValue("items_discount_amount", totals.items_discount_amount, opts);
setValue("discount_amount", totals.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
);
}