191 lines
6.3 KiB
TypeScript
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
|
|
);
|
|
}
|