2025-10-12 18:36:33 +00:00
|
|
|
import { TaxCatalogProvider } from "@erp/core";
|
2025-10-11 16:44:38 +00:00
|
|
|
import * as React from "react";
|
|
|
|
|
import { UseFormReturn } from "react-hook-form";
|
2025-10-12 18:36:33 +00:00
|
|
|
import {
|
|
|
|
|
InvoiceItemCalcResult,
|
|
|
|
|
calculateInvoiceHeaderAmounts,
|
|
|
|
|
calculateInvoiceItemAmounts,
|
|
|
|
|
} from "../../domain";
|
2025-10-12 10:43:06 +00:00
|
|
|
import { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
export type UseInvoiceAutoRecalcParams = {
|
|
|
|
|
currency_code: string;
|
|
|
|
|
taxCatalog: TaxCatalogProvider;
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-11 16:44:38 +00:00
|
|
|
/**
|
|
|
|
|
* Hook que recalcula automáticamente los totales de cada línea
|
|
|
|
|
* y los totales generales de la factura cuando cambian los valores relevantes.
|
2025-10-12 18:36:33 +00:00
|
|
|
* Adaptado a formulario con números planos (no DTOs).
|
2025-10-11 16:44:38 +00:00
|
|
|
*/
|
2025-10-12 18:36:33 +00:00
|
|
|
export function useInvoiceAutoRecalc(
|
|
|
|
|
form: UseFormReturn<InvoiceFormData>,
|
|
|
|
|
params: UseInvoiceAutoRecalcParams
|
|
|
|
|
) {
|
2025-10-11 16:44:38 +00:00
|
|
|
const {
|
|
|
|
|
watch,
|
|
|
|
|
setValue,
|
|
|
|
|
getValues,
|
2025-10-12 18:36:33 +00:00
|
|
|
trigger,
|
2025-10-11 16:44:38 +00:00
|
|
|
formState: { isDirty, isLoading, isSubmitting },
|
|
|
|
|
} = form;
|
|
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
const { currency_code, taxCatalog } = params;
|
|
|
|
|
|
|
|
|
|
// Cache de los totales de cada línea de factura
|
|
|
|
|
const itemCache = React.useRef<Map<number, ReturnType<typeof calculateInvoiceItemAmounts>>>(
|
|
|
|
|
new Map()
|
|
|
|
|
);
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Cache de los totales de la factura
|
|
|
|
|
const invoiceTotalsRef = React.useRef<ReturnType<typeof calculateInvoiceHeaderAmounts>>({
|
|
|
|
|
subtotal_amount: 0,
|
|
|
|
|
discount_amount: 0,
|
|
|
|
|
taxable_amount: 0,
|
|
|
|
|
taxes_amount: 0,
|
|
|
|
|
total_amount: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Cálculo de una línea (usa dominio puro)
|
2025-10-11 16:44:38 +00:00
|
|
|
const calculateItemTotals = React.useCallback(
|
2025-10-12 10:43:06 +00:00
|
|
|
(item: InvoiceItemFormData) => {
|
2025-10-12 18:36:33 +00:00
|
|
|
const sanitizeString = (v?: number | string) =>
|
|
|
|
|
v && !Number.isNaN(Number(v)) ? String(v) : "0";
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
return calculateInvoiceItemAmounts(
|
|
|
|
|
{
|
|
|
|
|
quantity: sanitizeString(item.quantity),
|
|
|
|
|
unit_amount: sanitizeString(item.unit_amount),
|
|
|
|
|
discount_percentage: sanitizeString(item.discount_percentage),
|
|
|
|
|
tax_codes: item.tax_codes,
|
|
|
|
|
},
|
|
|
|
|
currency_code,
|
|
|
|
|
taxCatalog
|
2025-10-11 16:44:38 +00:00
|
|
|
);
|
2025-10-12 18:36:33 +00:00
|
|
|
},
|
|
|
|
|
[taxCatalog, currency_code]
|
|
|
|
|
);
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Recalculo incremental de cabecera
|
|
|
|
|
const recalcInvoiceTotalsIncrementally = React.useCallback(
|
|
|
|
|
(
|
|
|
|
|
prevTotals: ReturnType<typeof calculateInvoiceHeaderAmounts>,
|
|
|
|
|
prevItem?: ReturnType<typeof calculateInvoiceItemAmounts>,
|
|
|
|
|
newItem?: ReturnType<typeof calculateInvoiceItemAmounts>
|
|
|
|
|
): ReturnType<typeof calculateInvoiceHeaderAmounts> => {
|
|
|
|
|
const adjust = (field: keyof typeof prevTotals) =>
|
|
|
|
|
prevTotals[field] - (prevItem?.[field] ?? 0) + (newItem?.[field] ?? 0);
|
2025-10-11 16:44:38 +00:00
|
|
|
|
|
|
|
|
return {
|
2025-10-12 18:36:33 +00:00
|
|
|
subtotal_amount: adjust("subtotal_amount"),
|
|
|
|
|
discount_amount: adjust("discount_amount"),
|
|
|
|
|
taxable_amount: adjust("taxable_amount"),
|
|
|
|
|
taxes_amount: adjust("taxes_amount"),
|
|
|
|
|
total_amount: adjust("total_amount"),
|
2025-10-11 16:44:38 +00:00
|
|
|
};
|
|
|
|
|
},
|
2025-10-12 18:36:33 +00:00
|
|
|
[]
|
2025-10-11 16:44:38 +00:00
|
|
|
);
|
|
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Totales globales (usa funciones del dominio)
|
2025-10-11 16:44:38 +00:00
|
|
|
const calculateInvoiceTotals = React.useCallback(
|
2025-10-12 10:43:06 +00:00
|
|
|
(items: InvoiceItemFormData[]) => {
|
2025-10-12 18:36:33 +00:00
|
|
|
const lines = items
|
|
|
|
|
.filter((i) => !i.is_non_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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
return calculateInvoiceHeaderAmounts(lines, currency_code);
|
2025-10-11 16:44:38 +00:00
|
|
|
},
|
2025-10-12 18:36:33 +00:00
|
|
|
[calculateItemTotals, currency_code]
|
2025-10-11 16:44:38 +00:00
|
|
|
);
|
|
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Suscripción reactiva a cambios del formulario
|
2025-10-11 16:44:38 +00:00
|
|
|
React.useEffect(() => {
|
2025-10-12 18:36:33 +00:00
|
|
|
if (!isDirty || isLoading || isSubmitting) return;
|
|
|
|
|
|
|
|
|
|
const subscription = watch(async (formData, { name, type }) => {
|
|
|
|
|
const items = (formData?.items || []) as InvoiceItemFormData[];
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
if (items.length === 0) return;
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Detectar cambios en la cabecera
|
|
|
|
|
if (name === "discount_percentage") {
|
|
|
|
|
// Recalcular totales de factura
|
|
|
|
|
const invoiceTotals = calculateInvoiceTotals(items);
|
|
|
|
|
|
|
|
|
|
// Cabecera
|
|
|
|
|
setInvoiceTotals(form, invoiceTotals);
|
|
|
|
|
|
|
|
|
|
// 3) valida una vez (opcional)
|
|
|
|
|
await trigger([
|
|
|
|
|
"subtotal_amount",
|
|
|
|
|
"discount_amount",
|
|
|
|
|
"taxable_amount",
|
|
|
|
|
"taxes_amount",
|
|
|
|
|
"total_amount",
|
|
|
|
|
]);
|
2025-10-11 16:44:38 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// 2. Cambio puntual de una línea
|
2025-10-11 16:44:38 +00:00
|
|
|
if (name?.startsWith("items.") && type === "change") {
|
|
|
|
|
const index = Number(name.split(".")[1]);
|
2025-10-12 18:36:33 +00:00
|
|
|
const field = name.split(".")[2];
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
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);
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// Si no hay cambios en los totales, no tocamos nada
|
|
|
|
|
const itemHasChanges =
|
|
|
|
|
prevTotals && JSON.stringify(prevTotals) !== JSON.stringify(newTotals);
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
if (!itemHasChanges) {
|
|
|
|
|
return;
|
2025-10-11 16:44:38 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
// 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 itemTotals = calculateItemTotals(item);
|
|
|
|
|
const invoiceTotals = calculateInvoiceTotals(items);
|
|
|
|
|
// Actualizar totales globales incrementalmente
|
|
|
|
|
//const prevTotals = invoiceTotalsRef.current;
|
|
|
|
|
//const newTotals = recalcTotalsIncrementally(prevTotals, prevLine, newLine);
|
|
|
|
|
//invoiceTotalsRef.current = newTotals;
|
2025-10-11 16:44:38 +00:00
|
|
|
|
2025-10-12 18:36:33 +00:00
|
|
|
console.log(invoiceTotals);
|
|
|
|
|
|
|
|
|
|
// Cabecera
|
|
|
|
|
setInvoiceTotals(form, invoiceTotals);
|
|
|
|
|
|
|
|
|
|
// 3) valida una vez (opcional)
|
|
|
|
|
await trigger([
|
|
|
|
|
"items",
|
|
|
|
|
"subtotal_amount",
|
|
|
|
|
"discount_amount",
|
|
|
|
|
"taxable_amount",
|
|
|
|
|
"taxes_amount",
|
|
|
|
|
"total_amount",
|
|
|
|
|
]);
|
2025-10-11 16:44:38 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => subscription.unsubscribe();
|
|
|
|
|
}, [
|
|
|
|
|
watch,
|
|
|
|
|
setValue,
|
|
|
|
|
getValues,
|
2025-10-12 18:36:33 +00:00
|
|
|
isLoading,
|
|
|
|
|
isSubmitting,
|
2025-10-11 16:44:38 +00:00
|
|
|
calculateItemTotals,
|
|
|
|
|
calculateInvoiceTotals,
|
|
|
|
|
]);
|
|
|
|
|
}
|
2025-10-12 18:36:33 +00:00
|
|
|
|
|
|
|
|
// Ayudante para rellenar los importes de una línea
|
|
|
|
|
function setInvoiceItemTotals(
|
|
|
|
|
form: UseFormReturn<InvoiceFormData>,
|
|
|
|
|
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<InvoiceFormData>,
|
|
|
|
|
invoiceTotals: ReturnType<typeof calculateInvoiceHeaderAmounts>
|
|
|
|
|
) {
|
|
|
|
|
const { setValue } = form;
|
|
|
|
|
|
|
|
|
|
setValue("subtotal_amount", invoiceTotals.subtotal_amount, {
|
|
|
|
|
shouldDirty: true,
|
|
|
|
|
shouldValidate: false,
|
|
|
|
|
});
|
|
|
|
|
setValue("discount_amount", invoiceTotals.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,
|
|
|
|
|
});
|
|
|
|
|
}
|