Facturas de cliente

This commit is contained in:
David Arranz 2025-10-11 18:44:38 +02:00
parent b4fb4902dc
commit b97d32b607
22 changed files with 536 additions and 220 deletions

View File

@ -0,0 +1,16 @@
import type { MoneyDTO, PercentageDTO, QuantityDTO } from "../dto";
/** MoneyDTO: { value, scale, currency_code } */
export function areMoneyDTOEqual(a?: MoneyDTO | null, b?: MoneyDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale && a?.currency_code === b?.currency_code;
}
/** QuantityDTO: { value, scale } */
export function areQuantityDTOEqual(a?: QuantityDTO | null, b?: QuantityDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale;
}
/** PercentageDTO: { value, scale } */
export function arePercentageDTOEqual(a?: PercentageDTO | null, b?: PercentageDTO | null): boolean {
return a?.value === b?.value && a?.scale === b?.scale;
}

View File

@ -1 +1,2 @@
export * from "./dto-compare-helper";
export * from "./money-utils";

View File

@ -19,7 +19,6 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
onDirtyChange,
...rest
}: UseHookFormProps<TFields, TContext>): UseFormReturn<TFields> {
const form = useForm<TFields, TContext>({
...rest,
resolver: zodResolver(resolverSchema),

View File

@ -225,11 +225,15 @@
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#if taxes_amount }}
<tr>
<td class="px-4 text-right">IVA&nbsp;21%</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{else}}
<!-- iva 0-->
{{/if}}
<tr class="">
<td class="px-4 text-right accent-color">
Total&nbsp;factura

View File

@ -40,11 +40,12 @@ const taxesList = [
interface CustomerInvoiceTaxesMultiSelect {
value: string[];
onChange: (selectedValues: string[]) => void;
className?: string;
[key: string]: any; // Allow other props to be passed
}
export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => {
const { value, onChange, ...otherProps } = props;
const { value, onChange, className, ...otherProps } = props;
const { t } = useTranslation();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
@ -78,6 +79,12 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
maxCount={3}
autoFilter={true}
filterSelected={filterSelectedByGroup}
className={cn(
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border min-h-8 h-auto items-center justify-between bg-background hover:bg-inherit [&_svg]:pointer-events-auto",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className
)}
{...otherProps}
/>
</div>

View File

@ -1,6 +1,6 @@
import { PropsWithChildren } from "react";
import { CustomerInvoicesProvider } from "../context";
import { InvoiceProvider } from "../context";
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
return <CustomerInvoicesProvider>{children}</CustomerInvoicesProvider>;
return <InvoiceProvider>{children}</InvoiceProvider>;
};

View File

@ -26,7 +26,7 @@ import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
export const CustomerInvoicesListGrid = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format } = useMoney();
const { formatCurrency } = useMoney();
const {
data: invoices,
@ -50,22 +50,26 @@ export const CustomerInvoicesListGrid = () => {
{
field: "invoice_number",
headerName: t("pages.list.grid_columns.invoice_number"),
cellClass: "tabular-nums",
minWidth: 130,
},
{
field: "series",
headerName: t("pages.list.grid_columns.series"),
cellClass: "tabular-nums",
minWidth: 80,
},
{
field: "invoice_date",
headerName: t("pages.list.grid_columns.invoice_date"),
valueFormatter: (p: ValueFormatterParams) => formatDate(p.value),
cellClass: "tabular-nums",
minWidth: 130,
},
{
field: "recipient.tin",
headerName: t("pages.list.grid_columns.recipient_tin"),
cellClass: "tabular-nums",
minWidth: 130,
},
{
@ -94,7 +98,7 @@ export const CustomerInvoicesListGrid = () => {
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? format(raw) : "—";
return raw ? formatCurrency(raw) : "—";
},
cellClass: "tabular-nums",
minWidth: 130,
@ -105,7 +109,7 @@ export const CustomerInvoicesListGrid = () => {
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? format(raw) : "—";
return raw ? formatCurrency(raw) : "—";
},
cellClass: "tabular-nums",
minWidth: 130,
@ -116,7 +120,7 @@ export const CustomerInvoicesListGrid = () => {
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => {
const raw: MoneyDTO | null = params.value;
return raw ? format(raw) : "—";
return raw ? formatCurrency(raw) : "—";
},
cellClass: "tabular-nums font-semibold",
minWidth: 140,

View File

@ -108,6 +108,7 @@ export function AmountDTOInput({
pattern={focused ? '[0-9]*[.,]?[0-9]*' : undefined}
className={cn(
"w-full bg-transparent p-0 text-right focus:outline-none tabular-nums focus:bg-background",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className
)}
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}

View File

@ -1,90 +1,122 @@
import { useMoney } from '@erp/core/hooks';
import { InvoiceItemTotals } from '@erp/customer-invoices/web/hooks/calcs';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
HoverCard, HoverCardContent, HoverCardTrigger
} from "@repo/shadcn-ui/components";
import { PropsWithChildren } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../i18n";
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
totals: InvoiceItemTotals
type HoverCardTotalsSummaryProps = PropsWithChildren & {
rowIndex: number;
}
/**
* Muestra un desglose financiero del total de línea.
* Lee directamente los importes del formulario vía react-hook-form.
*/
export const HoverCardTotalsSummary = ({
children,
totals
rowIndex,
}: HoverCardTotalsSummaryProps) => {
const { t } = useTranslation();
const { formatCurrency } = useMoney();
const { control } = useFormContext();
// 👀 Observar los valores actuales del formulario
const [subtotal, discountPercentage, discountAmount, taxableBase, total] =
useWatch({
control,
name: [
`items.${rowIndex}.subtotal_amount`,
`items.${rowIndex}.discount_percentage`,
`items.${rowIndex}.discount_amount`,
`items.${rowIndex}.taxable_base`,
`items.${rowIndex}.total_amount`,
],
});
const SummaryBlock = () => (
<div className="space-y-2">
<h4 className="text-sm font-semibold mb-3">{t("components.hover_card_totals_summary.label")}</h4>
<h4 className="text-sm font-semibold mb-3">
{t("components.hover_card_totals_summary.label")}
</h4>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.subtotal_amount")}:</span>
<span className="font-mono">{formatCurrency(totals.subtotal)}</span>
</div>
{(totals.discountPercent ?? 0) > 0 && (
{/* Subtotal */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.discount_percentage")} ({totals.discountPercent ?? 0}%):
{t("components.hover_card_totals_summary.fields.subtotal_amount")}:
</span>
<span className="font-mono">{formatCurrency(subtotal)}</span>
</div>
{/* Descuento (si aplica) */}
{discountPercentage && Number(discountPercentage.value) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t(
"components.hover_card_totals_summary.fields.discount_percentage"
)}{" "}
({discountPercentage && discountPercentage.value
? (Number(discountPercentage.value) /
10 ** Number(discountPercentage.scale)) *
100
: 0}
%):
</span>
<span className="font-mono text-destructive">
-{formatCurrency(totals.discountAmount)}
-{formatCurrency(discountAmount)}
</span>
</div>
)}
{/* Base imponible */}
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.taxable_amount")}:</span>
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.taxable_amount")}:
</span>
<span className="font-mono font-medium">
{formatCurrency(totals.taxableBase)}
{formatCurrency(taxableBase)}
</span>
</div>
{/*totals.taxesBreakdown.map((tax) => (
<div key={tax.label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{tax.label}:</span>
<span className="font-mono">{formatCurrency(tax.amount)}</span>
</div>
))*/}
{/* Total final */}
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
<span>{t("components.hover_card_totals_summary.fields.total_amount")}:</span>
<span className="font-mono">{formatCurrency(totals.total)}</span>
<span>
{t("components.hover_card_totals_summary.fields.total_amount")}:
</span>
<span className="font-mono">{formatCurrency(total)}</span>
</div>
</div>
)
);
return (
<>
{/* Variante móvil */}
{/* Variante móvil (Dialog) */}
<div className="md:hidden">
<Dialog>
<DialogTrigger>{children}</DialogTrigger>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Desglose del importe</DialogTitle>
<DialogTitle>
{t("components.hover_card_totals_summary.label")}
</DialogTitle>
</DialogHeader>
<SummaryBlock />
</DialogContent>
</Dialog>
</div>
{/* Variante desktop */}
{/* Variante escritorio (HoverCard) */}
<div className="hidden md:block">
<HoverCard>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent className="w-64" align="end">
<SummaryBlock />
</HoverCardContent>
</HoverCard>
</div>
</>
)
}
);
};

View File

@ -1,8 +1,6 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useEffect } from 'react';
import { Controller, useFormContext } from "react-hook-form";
import { useCalcInvoiceItemTotals } from '../../../hooks';
import { Control, Controller } from "react-hook-form";
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
@ -12,7 +10,7 @@ import { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
export type ItemRowProps = {
control: Control,
item: CustomerInvoiceItemFormData;
rowIndex: number;
isSelected: boolean;
@ -27,7 +25,9 @@ export type ItemRowProps = {
}
export const ItemRow = ({ item,
export const ItemRow = ({
control,
item,
rowIndex,
isSelected,
isFirst,
@ -40,19 +40,6 @@ export const ItemRow = ({ item,
onRemove, }: ItemRowProps) => {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const totals = useCalcInvoiceItemTotals(item);
console.log(totals);
// sincroniza el total con el form
useEffect(() => {
if (totals?.totalDTO) {
setValue?.(`items.${rowIndex}.total_amount`, totals.totalDTO);
}
}, [totals.totalDTO, control, rowIndex]);
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
@ -146,10 +133,9 @@ export const ItemRow = ({ item,
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<HoverCardTotalsSummary totals={totals}>
<HoverCardTotalsSummary rowIndex={rowIndex} >
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}

View File

@ -40,7 +40,6 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
const { control, watch } = form;
// Emitir cambios a quien consuma el componente
React.useEffect(() => {
const sub = watch((v) => onChange?.(v.items ?? []));
@ -104,6 +103,7 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
{tableNav.fa.fields.map((f, rowIndex) => (
<ItemRow
key={f.id}
control={control}
item={form.watch(`items.${rowIndex}`)}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}

View File

@ -99,7 +99,10 @@ export function PercentageDTOInput({
aria-label={ariaLabel}
inputMode="decimal"
pattern={focused ? "[0-9]*[.,]?[0-9]*%?" : undefined}
className={cn("w-full bg-transparent p-0 text-right tabular-nums", className)}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 focus:bg-background",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className)}
readOnly={readOnly}
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
value={raw}

View File

@ -66,6 +66,119 @@ export function QuantityDTOInput({
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
}, [value, toNumber, formatPlain, suffixFor, nbspBeforeSuffix, emptyMode, emptyText]);
const formatNumber = React.useCallback((value: number, locale: string, sc: number) => {
return new Intl.NumberFormat(locale, {
maximumFractionDigits: sc,
minimumFractionDigits: 0,
useGrouping: false,
}).format(value);
}, []);
const numberFmt = React.useMemo(
() => new Intl.NumberFormat(locale, { maximumFractionDigits: sc, minimumFractionDigits: 0, useGrouping: false }),
[locale, sc]
);
const formatDisplay = React.useCallback(
(value: number) => {
const numTxt = numberFmt.format(value);
const suf = suffixFor(value);
return suf
? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}`
: numTxt;
},
[numberFmt, suffixFor, nbspBeforeSuffix]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
// Casos vacíos
if (txt === "" || isShowingEmptyValue) {
React.startTransition(() => {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
});
return;
}
const n = parse(txt);
if (n === null) {
React.startTransition(() => {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
});
return;
}
const rounded = roundToScale(n, sc);
const formatted = formatDisplay(rounded);
// Actualiza en transición concurrente (no bloquea UI)
React.startTransition(() => {
onChange(fromNumber(rounded, sc));
setRaw(formatted);
});
},
[
sc,
parse,
formatDisplay,
roundToScale,
fromNumber,
onChange,
emptyMode,
emptyText,
isShowingEmptyValue,
]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
const val = e.currentTarget.value;
// Si muestra el placeholder "vacío lógico", limpiar
if (emptyMode === "value" && val === emptyText) {
setRaw("");
return;
}
// Intenta parsear lo visible, o usa valor actual si no hay parse
const parsed =
parse(val) ??
(!isEmptyQuantityDTO(value) ? toNumber(value!) : null);
setRaw(parsed !== null ? String(parsed) : "");
},
[emptyMode, emptyText, parse, value, toNumber]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (readOnly) return;
const { key, shiftKey } = e;
if (key !== "ArrowUp" && key !== "ArrowDown") return;
e.preventDefault();
// Base numérica a partir del texto actual
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
// Cálculo de incremento/decremento
const delta = (shiftKey ? 10 : 1) * step * (key === "ArrowUp" ? 1 : -1);
const next = roundToScale(base + delta, sc);
React.startTransition(() => {
onChange(fromNumber(next, sc));
setRaw(String(next));
});
},
[readOnly, parse, raw, isShowingEmptyValue, step, sc, onChange, fromNumber, roundToScale]
);
React.useEffect(() => {
if (focused) return;
@ -99,52 +212,20 @@ export function QuantityDTOInput({
id={id}
inputMode="decimal"
pattern={focused ? "[0-9]*[.,]?[0-9]*" : undefined}
className={cn("w-full bg-transparent p-0 text-right tabular-nums h-8 leading-5 focus:bg-background", className)}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
placeholder={emptyMode === "placeholder" && isEmptyQuantityDTO(value) ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
// Pasar de visual (con posible sufijo) → crudo editable
if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); return; }
const n = parse(e.currentTarget.value) ?? (isEmptyQuantityDTO(value) ? null : toNumber(value!));
setRaw(n !== null ? String(n) : "");
}}
onBlur={(e) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, sc);
onChange(fromNumber(rounded, sc));
// Visual con posible sufijo
const numTxt = new Intl.NumberFormat(locale, {
maximumFractionDigits: sc,
minimumFractionDigits: Number.isInteger(rounded) ? 0 : 0,
useGrouping: false,
}).format(rounded);
const suf = suffixFor(rounded);
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
}}
onKeyDown={(e) => {
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1);
const rounded = roundToScale(base + delta, sc);
onChange(fromNumber(rounded, sc));
setRaw(String(rounded)); // crudo mientras edita
}
}}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -1,55 +0,0 @@
import { PropsWithChildren, createContext } from "react";
/**
*
* 💡 Posibles usos del InvoicingContext
*
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
* bounded context de facturación (facturas), proporcionando acceso global a datos
* o funciones relevantes para múltiples vistas (listado, detalle, edición, etc).
*
* Usos recomendados:
*
* 1. 🔎 Gestión de filtros globales:
* - Permite que los filtros aplicados en el listado de facturas se conserven
* cuando el usuario navega a otras vistas (detalle, edición) y luego regresa.
* - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros.
*
* 2. 🛡 Gestión de permisos o configuración de acciones disponibles:
* - Permite definir qué acciones están habilitadas para el usuario actual
* (crear, editar, eliminar).
* - Útil para mostrar u ocultar botones de acción en diferentes pantallas.
*
* 3. 🧭 Control del layout:
* - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global),
* este contexto puede coordinar su estado desde componentes hijos.
* - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas.
*
* 4. 📦 Cacheo liviano de datos compartidos:
* - Puede almacenar la última factura abierta, borradores de edición,
* o referencias temporales para operaciones CRUD sin tener que usar la URL.
*
* 5. 🚀 Coordinación de side-effects:
* - Permite exponer funciones comunes como `refetch`, `resetFilters`,
* o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio.
*
* Alternativas:
* - Si el estado compartido es muy mutable, grande o requiere persistencia,
* podría ser preferible usar Zustand o Redux Toolkit.
* - No usar contextos para valores que cambian frecuentemente en tiempo real,
* ya que pueden causar renders innecesarios.
*
*
*/
export type CustomerInvoicesContextType = {};
export type CustomerInvoicesContextParamsType = {
//service: CustomerInvoiceApplicationService;
};
export const CustomerInvoicesContext = createContext<CustomerInvoicesContextType>({});
export const CustomerInvoicesProvider = ({ children }: PropsWithChildren) => {
return <CustomerInvoicesContext.Provider value={{}}>{children}</CustomerInvoicesContext.Provider>;
};

View File

@ -1 +1 @@
export * from "./customer-invoices-context";
export * from "./invoice-context";

View File

@ -0,0 +1,62 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
export type InvoiceContextValue = {
company_id: string;
currency_code: string;
language_code: string;
is_proforma: boolean;
changeLanguage: (lang: string) => void;
changeCurrency: (currency: string) => void;
changeIsProforma: (value: boolean) => void;
};
const InvoiceContext = createContext<InvoiceContextValue | null>(null);
export interface InvoiceProviderParams {
company_id: string;
language_code?: string; // default "es"
currency_code?: string; // default "EUR"
is_proforma?: boolean; // default 'true'
children: React.ReactNode;
}
export const InvoiceProvider = ({ company_id, language_code: initialLang = "es",
currency_code: initialCurrency = "EUR",
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
// Estado interno local para campos dinámicos
const [language_code, setLanguage] = useState(initialLang);
const [currency_code, setCurrency] = useState(initialCurrency);
const [is_proforma, setIsProforma] = useState(initialProforma);
// Callbacks memoizados
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
const setCurrencyMemo = useCallback((currency_code: string) => setCurrency(currency_code), []);
const setIsProformaMemo = useCallback((is_proforma: boolean) => setIsProforma(is_proforma), []);
const value = useMemo<InvoiceContextValue>(() => {
return {
company_id,
language_code,
currency_code,
is_proforma,
changeLanguage: setLanguageMemo,
changeCurrency: setCurrencyMemo,
changeIsProforma: setIsProformaMemo
}
}, [company_id, language_code, currency_code, is_proforma, setLanguageMemo, setCurrencyMemo, setIsProformaMemo]);
return <InvoiceContext.Provider value={value}>{children}</InvoiceContext.Provider>;
};
export function useInvoiceContext(): InvoiceContextValue {
const context = useContext(InvoiceContext);
if (!context) {
throw new Error("useInvoiceContext must be used within <InvoiceProvider>");
}
return context;
}

View File

@ -1,2 +1 @@
export * from "./use-calc-invoice-items-totals";
export * from "./use-calc-invoice-totals";
export * from "./use-invoice-auto-recalc";

View File

@ -0,0 +1,178 @@
import { areMoneyDTOEqual } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import * as React from "react";
import { UseFormReturn } from "react-hook-form";
import { CustomerInvoiceFormData, CustomerInvoiceItemFormData } from "../../schemas";
/**
* Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes.
*/
export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData>) {
const {
watch,
setValue,
getValues,
formState: { isDirty, isLoading, isSubmitting },
} = form;
const moneyHelper = useMoney();
const qtyHelper = useQuantity();
const pctHelper = usePercentage();
// Cálculo de una línea
const calculateItemTotals = React.useCallback(
(item: CustomerInvoiceItemFormData) => {
if (!item) {
const zero = moneyHelper.fromNumber(0);
return {
subtotalDTO: zero,
discountAmountDTO: zero,
taxableBaseDTO: zero,
taxesDTO: zero,
totalDTO: zero,
};
}
// Subtotal = unit_amount × quantity
const subtotalDTO = moneyHelper.multiply(item.unit_amount, qtyHelper.toNumber(item.quantity));
// Descuento = subtotal × (discount_percentage / 100)
const discountDTO = moneyHelper.percentage(
subtotalDTO,
pctHelper.toNumber(item.discount_percentage)
);
// Base imponible = subtotal descuento
const taxableBaseDTO = moneyHelper.sub(subtotalDTO, discountDTO);
// Impuestos (placeholder: se integrará con tax catalog)
const taxesDTO = moneyHelper.fromNumber(0);
// Total = base imponible + impuestos
const totalDTO = moneyHelper.add(taxableBaseDTO, taxesDTO);
return {
subtotalDTO,
discountAmountDTO: discountDTO,
taxableBaseDTO,
taxesDTO,
totalDTO,
};
},
[moneyHelper, qtyHelper, pctHelper]
);
// Cálculo de los totales de la factura a partir de los conceptos
const calculateInvoiceTotals = React.useCallback(
(items: CustomerInvoiceItemFormData[]) => {
let subtotalDTO = moneyHelper.fromNumber(0);
let discountTotalDTO = moneyHelper.fromNumber(0);
let taxableBaseDTO = moneyHelper.fromNumber(0);
let taxesDTO = moneyHelper.fromNumber(0);
let totalDTO = moneyHelper.fromNumber(0);
for (const item of items) {
const t = calculateItemTotals(item);
subtotalDTO = moneyHelper.add(subtotalDTO, t.subtotalDTO);
discountTotalDTO = moneyHelper.add(discountTotalDTO, t.discountAmountDTO);
taxableBaseDTO = moneyHelper.add(taxableBaseDTO, t.taxableBaseDTO);
taxesDTO = moneyHelper.add(taxesDTO, t.taxesDTO);
totalDTO = moneyHelper.add(totalDTO, t.totalDTO);
}
return {
subtotalDTO,
discountTotalDTO,
taxableBaseDTO,
taxesDTO,
totalDTO,
};
},
[moneyHelper, calculateItemTotals]
);
// Suscribirse a cambios del formulario
React.useEffect(() => {
if (!isDirty || isLoading || isSubmitting) {
return;
}
const subscription = watch((formData, { name, type }) => {
if (!formData?.items?.length) return;
// 1. Si cambia una línea completa (add/remove/move)
if (name === "items" && type === "change") {
formData.items.forEach((item, i) => {
if (!item) return;
const typedItem = item as CustomerInvoiceItemFormData;
const totals = calculateItemTotals(typedItem);
const current = getValues(`items.${i}.total_amount`);
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
setValue(`items.${i}.total_amount`, totals.totalDTO, {
shouldDirty: true,
shouldValidate: false,
});
}
});
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as CustomerInvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
setValue("discount_amount", totalsGlobal.discountTotalDTO);
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
setValue("taxes_amount", totalsGlobal.taxesDTO);
setValue("total_amount", totalsGlobal.totalDTO);
}
// 2. Si cambia un campo dentro de un concepto
if (name?.startsWith("items.") && type === "change") {
const index = Number(name.split(".")[1]);
const fieldName = name.split(".")[2];
if (["quantity", "unit_amount", "discount_percentage"].includes(fieldName)) {
const typedItem = formData.items[index] as CustomerInvoiceItemFormData;
if (!typedItem) return;
// Recalcular línea
const totals = calculateItemTotals(typedItem);
const current = getValues(`items.${index}.total_amount`);
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
setValue(`items.${index}.total_amount`, totals.totalDTO, {
shouldDirty: true,
shouldValidate: false,
});
}
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as CustomerInvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
setValue("discount_amount", totalsGlobal.discountTotalDTO);
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
setValue("taxes_amount", totalsGlobal.taxesDTO);
setValue("total_amount", totalsGlobal.totalDTO);
}
}
});
return () => subscription.unsubscribe();
}, [
watch,
isDirty,
isLoading,
isSubmitting,
setValue,
getValues,
calculateItemTotals,
calculateInvoiceTotals,
]);
}

View File

@ -1,7 +1,6 @@
export * from "./calcs";
export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoice-query";
export * from "./use-customer-invoices-context";
export * from "./use-customer-invoices-query";
export * from "./use-detail-columns";
export * from "./use-items-table-navigation";

View File

@ -1,11 +0,0 @@
import { useContext } from "react";
import { CustomerInvoicesContext, CustomerInvoicesContextType } from "../context";
export const useCustomerInvoicesContext = (): CustomerInvoicesContextType => {
const context = useContext(CustomerInvoicesContext);
if (!context) {
throw new Error("useCustomerInvoices must be used within a CustomerInvoicesProvider");
}
return context;
};

View File

@ -16,7 +16,8 @@ import {
CustomerInvoiceEditorSkeleton,
PageHeader,
} from "../../components";
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
import { InvoiceProvider } from '../../context';
import { useCustomerInvoiceQuery, useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
CustomerInvoiceFormData,
@ -46,13 +47,15 @@ export const CustomerInvoiceUpdatePage = () => {
} = useUpdateCustomerInvoice();
// 3) Form hook
const form = useHookForm<CustomerInvoiceFormData>({
resolverSchema: CustomerInvoiceFormSchema,
initialValues: (invoiceData as unknown as CustomerInvoiceFormData) ?? defaultCustomerInvoiceFormData,
disabled: isUpdating,
});
// 4) Activa recálculo automático de los totales de la factura cuando hay algún cambio en importes
useInvoiceAutoRecalc(form);
const handleSubmit = (formData: CustomerInvoiceFormData) => {
const { dirtyFields } = form.formState;
@ -131,6 +134,11 @@ export const CustomerInvoiceUpdatePage = () => {
);
return (
<InvoiceProvider
company_id={invoiceData.company_id}
language_code={invoiceData.language_code}
currency_code={invoiceData.currency_code}
>
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<AppBreadcrumb />
@ -178,5 +186,6 @@ export const CustomerInvoiceUpdatePage = () => {
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
</InvoiceProvider>
);
};

View File

@ -1,4 +1,5 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { ArrayElement } from "@repo/rdx-utils";
import { z } from "zod/v4";
export const CustomerInvoiceItemFormSchema = z.object({
@ -79,7 +80,7 @@ export const CustomerInvoiceFormSchema = z.object({
});
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
export type CustomerInvoiceItemFormData = ArrayElement<CustomerInvoiceFormData["items"]>;
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
description: "",