diff --git a/modules/core/src/common/helpers/dto-compare-helper.ts b/modules/core/src/common/helpers/dto-compare-helper.ts new file mode 100644 index 00000000..5f336600 --- /dev/null +++ b/modules/core/src/common/helpers/dto-compare-helper.ts @@ -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; +} diff --git a/modules/core/src/common/helpers/index.ts b/modules/core/src/common/helpers/index.ts index 0ad0423e..f73ab18f 100644 --- a/modules/core/src/common/helpers/index.ts +++ b/modules/core/src/common/helpers/index.ts @@ -1 +1,2 @@ +export * from "./dto-compare-helper"; export * from "./money-utils"; diff --git a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts index 8056c894..1fd33a75 100644 --- a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts +++ b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts @@ -19,7 +19,6 @@ export function useHookForm): UseFormReturn { - const form = useForm({ ...rest, resolver: zodResolver(resolverSchema), diff --git a/modules/customer-invoices/src/api/application/use-cases/report/reporter/templates/customer-invoice/template-factura.hbs b/modules/customer-invoices/src/api/application/use-cases/report/reporter/templates/customer-invoice/template-factura.hbs index ed5feca7..d1f86fb8 100644 --- a/modules/customer-invoices/src/api/application/use-cases/report/reporter/templates/customer-invoice/template-factura.hbs +++ b/modules/customer-invoices/src/api/application/use-cases/report/reporter/templates/customer-invoice/template-factura.hbs @@ -225,11 +225,15 @@   {{taxable_amount}} + {{#if taxes_amount }} IVA 21%   {{taxes_amount}} + {{else}} + + {{/if}} Total factura diff --git a/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx b/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx index 0c94822b..992183c9 100644 --- a/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoice-taxes-multi-select.tsx @@ -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} /> diff --git a/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx b/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx index 5e4810d3..5fa65fad 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from "react"; -import { CustomerInvoicesProvider } from "../context"; +import { InvoiceProvider } from "../context"; export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => { - return {children}; + return {children}; }; diff --git a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx index edebb5b7..93c939b3 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx @@ -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, diff --git a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx index 882ed974..57341441 100644 --- a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx @@ -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} diff --git a/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx b/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx index 31b78921..16b96951 100644 --- a/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx @@ -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 = () => (
-

{t("components.hover_card_totals_summary.label")}

+

+ {t("components.hover_card_totals_summary.label")} +

+ {/* Subtotal */}
- {t("components.hover_card_totals_summary.fields.subtotal_amount")}: - {formatCurrency(totals.subtotal)} + + {t("components.hover_card_totals_summary.fields.subtotal_amount")}: + + {formatCurrency(subtotal)}
- {(totals.discountPercent ?? 0) > 0 && ( + {/* Descuento (si aplica) */} + {discountPercentage && Number(discountPercentage.value) > 0 && (
- {t("components.hover_card_totals_summary.fields.discount_percentage")} ({totals.discountPercent ?? 0}%): + {t( + "components.hover_card_totals_summary.fields.discount_percentage" + )}{" "} + ({discountPercentage && discountPercentage.value + ? (Number(discountPercentage.value) / + 10 ** Number(discountPercentage.scale)) * + 100 + : 0} + %): - -{formatCurrency(totals.discountAmount)} + -{formatCurrency(discountAmount)}
)} + {/* Base imponible */}
- {t("components.hover_card_totals_summary.fields.taxable_amount")}: + + {t("components.hover_card_totals_summary.fields.taxable_amount")}: + - {formatCurrency(totals.taxableBase)} + {formatCurrency(taxableBase)}
- {/*totals.taxesBreakdown.map((tax) => ( -
- {tax.label}: - {formatCurrency(tax.amount)} -
- ))*/} - + {/* Total final */}
- {t("components.hover_card_totals_summary.fields.total_amount")}: - {formatCurrency(totals.total)} + + {t("components.hover_card_totals_summary.fields.total_amount")}: + + {formatCurrency(total)}
- ) + ); return ( <> - {/* Variante móvil */} + {/* Variante móvil (Dialog) */}
- {children} + {children} - Desglose del importe + + {t("components.hover_card_totals_summary.label")} +
- {/* Variante desktop */} + {/* Variante escritorio (HoverCard) */}
- {children} + {children}
- ) -} \ No newline at end of file + ); +}; diff --git a/modules/customer-invoices/src/web/components/editor/items/item-row.tsx b/modules/customer-invoices/src/web/components/editor/items/item-row.tsx index 327e9384..54e74c38 100644 --- a/modules/customer-invoices/src/web/components/editor/items/item-row.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/item-row.tsx @@ -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 ( {/* selección */} @@ -146,10 +133,9 @@ export const ItemRow = ({ item, /> - {/* total (solo lectura) */} - + { 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) => ( { + 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) => { + 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) => { + 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) => { + 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} /> ); } \ No newline at end of file diff --git a/modules/customer-invoices/src/web/context/customer-invoices-context.tsx b/modules/customer-invoices/src/web/context/customer-invoices-context.tsx deleted file mode 100644 index 6a2b64aa..00000000 --- a/modules/customer-invoices/src/web/context/customer-invoices-context.tsx +++ /dev/null @@ -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({}); - -export const CustomerInvoicesProvider = ({ children }: PropsWithChildren) => { - return {children}; -}; diff --git a/modules/customer-invoices/src/web/context/index.ts b/modules/customer-invoices/src/web/context/index.ts index d667dc8a..07d8befe 100644 --- a/modules/customer-invoices/src/web/context/index.ts +++ b/modules/customer-invoices/src/web/context/index.ts @@ -1 +1 @@ -export * from "./customer-invoices-context"; +export * from "./invoice-context"; diff --git a/modules/customer-invoices/src/web/context/invoice-context.tsx b/modules/customer-invoices/src/web/context/invoice-context.tsx new file mode 100644 index 00000000..2e36b03e --- /dev/null +++ b/modules/customer-invoices/src/web/context/invoice-context.tsx @@ -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(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) => { + + // 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(() => { + + 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 {children}; +}; + + +export function useInvoiceContext(): InvoiceContextValue { + const context = useContext(InvoiceContext); + if (!context) { + throw new Error("useInvoiceContext must be used within "); + } + return context; +} \ No newline at end of file diff --git a/modules/customer-invoices/src/web/hooks/calcs/index.ts b/modules/customer-invoices/src/web/hooks/calcs/index.ts index 95ae5e4e..8c2e9b38 100644 --- a/modules/customer-invoices/src/web/hooks/calcs/index.ts +++ b/modules/customer-invoices/src/web/hooks/calcs/index.ts @@ -1,2 +1 @@ -export * from "./use-calc-invoice-items-totals"; -export * from "./use-calc-invoice-totals"; +export * from "./use-invoice-auto-recalc"; 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 new file mode 100644 index 00000000..d16959e0 --- /dev/null +++ b/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts @@ -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) { + 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, + ]); +} diff --git a/modules/customer-invoices/src/web/hooks/index.ts b/modules/customer-invoices/src/web/hooks/index.ts index 63bc383a..971bd782 100644 --- a/modules/customer-invoices/src/web/hooks/index.ts +++ b/modules/customer-invoices/src/web/hooks/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx deleted file mode 100644 index 06e065d5..00000000 --- a/modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx +++ /dev/null @@ -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; -}; diff --git a/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx b/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx index 395c4743..5c1de30c 100644 --- a/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx +++ b/modules/customer-invoices/src/web/pages/update/customer-invoices-update-page.tsx @@ -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({ 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,52 +134,58 @@ export const CustomerInvoiceUpdatePage = () => { ); return ( - - - - - {t("pages.edit.title")} {invoiceData.invoice_number} - - } - description={t("pages.edit.description")} - icon={} - rightSlot={ - - } - /> - - - - {/* Alerta de error de actualización (si ha fallado el último intento) */} - {isUpdateError && ( - + + + + + {t("pages.edit.title")} {invoiceData.invoice_number} + + } + description={t("pages.edit.description")} + icon={} + rightSlot={ + } /> - )} + - - - - - + + {/* Alerta de error de actualización (si ha fallado el último intento) */} + {isUpdateError && ( + + )} + + + + + + + ); }; diff --git a/modules/customer-invoices/src/web/schemas/customer-invoices.form.schema.ts b/modules/customer-invoices/src/web/schemas/customer-invoices.form.schema.ts index c3a2c1a9..eeec8522 100644 --- a/modules/customer-invoices/src/web/schemas/customer-invoices.form.schema.ts +++ b/modules/customer-invoices/src/web/schemas/customer-invoices.form.schema.ts @@ -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; -export type CustomerInvoiceItemFormData = z.infer; +export type CustomerInvoiceItemFormData = ArrayElement; export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = { description: "",