diff --git a/modules/core/src/common/helpers/money-helper.ts b/modules/core/src/common/helpers/money-helper.ts index e9ccfa87..a538111a 100644 --- a/modules/core/src/common/helpers/money-helper.ts +++ b/modules/core/src/common/helpers/money-helper.ts @@ -2,6 +2,21 @@ * Funciones para manipular valores monetarios numéricos. */ +export const formatCurrency = ( + amount: number, + scale: number = 2, + currency = "EUR", + locale = "es-ES" +) => { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + maximumFractionDigits: scale, + minimumFractionDigits: Number.isInteger(amount) ? 0 : 0, + useGrouping: true, + }).format(amount); +}; + /** * Elimina símbolos de moneda y caracteres no numéricos. * @param s Texto de entrada, e.g. "€ 1.234,56" diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index 415d0c9b..0b518675 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@hookform/devtools": "^4.4.0", + "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", diff --git a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx index 639234d8..1658f2aa 100644 --- a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx +++ b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx @@ -1,11 +1,10 @@ import { FieldErrors, useFormContext } from "react-hook-form"; -import { FormDebug } from "@erp/core/components"; import { cn } from '@repo/shadcn-ui/lib/utils'; import { InvoiceFormData } from "../../schemas"; import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields"; import { InvoiceItems } from './invoice-items-editor'; -import { InvoiceNotes } from './invoice-tax-notes'; +import { InvoiceNotes } from './invoice-notes'; import { InvoiceTaxSummary } from './invoice-tax-summary'; import { InvoiceTotals } from './invoice-totals'; import { InvoiceRecipient } from "./recipient"; @@ -25,12 +24,11 @@ export const CustomerInvoiceEditForm = ({ }: CustomerInvoiceFormProps) => { const form = useFormContext(); - console.log("CustomerInvoiceEditForm") return (
- +
@@ -45,21 +43,20 @@ export const CustomerInvoiceEditForm = ({
-
+ +
+ +
+ +
+ +
-
- -
- -
- -
-
); diff --git a/modules/customer-invoices/src/web/components/editor/invoice-tax-notes.tsx b/modules/customer-invoices/src/web/components/editor/invoice-notes.tsx similarity index 100% rename from modules/customer-invoices/src/web/components/editor/invoice-tax-notes.tsx rename to modules/customer-invoices/src/web/components/editor/invoice-notes.tsx diff --git a/modules/customer-invoices/src/web/components/editor/invoice-tax-summary.tsx b/modules/customer-invoices/src/web/components/editor/invoice-tax-summary.tsx index 522e7872..78e76f25 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-tax-summary.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-tax-summary.tsx @@ -1,14 +1,18 @@ +import { formatCurrency } from '@erp/core'; import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components"; import { Badge } from "@repo/shadcn-ui/components"; import { ReceiptIcon } from "lucide-react"; import { ComponentProps } from 'react'; import { useFormContext, useWatch } from "react-hook-form"; +import { useInvoiceContext } from '../../context'; import { useTranslation } from "../../i18n"; import { InvoiceFormData } from "../../schemas"; export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => { const { t } = useTranslation(); const { control } = useFormContext(); + const { currency_code, language_code } = useInvoiceContext(); + const taxes = useWatch({ control, @@ -16,15 +20,6 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => { defaultValue: [], }); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("es-ES", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount); - }; - const displayTaxes = taxes || []; return ( @@ -37,21 +32,22 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
{displayTaxes.map((tax, index) => ( -
+ +
- + {tax.tax_label}
- Base para el impuesto: - {formatCurrency(tax.taxable_amount)} + Base para el impuesto: + {formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}
- Importe de impuesto: - - {formatCurrency(tax.taxes_amount)} + Importe de impuesto: + + {formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
diff --git a/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx b/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx index e0999029..79ba1405 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx @@ -1,56 +1,17 @@ +import { formatCurrency } from '@erp/core'; import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components"; import { Input, Label, Separator } from "@repo/shadcn-ui/components"; import { CalculatorIcon } from "lucide-react"; import { ComponentProps } from 'react'; import { Controller, useFormContext } from "react-hook-form"; +import { useInvoiceContext } from '../../context'; import { useTranslation } from "../../i18n"; import { InvoiceFormData } from "../../schemas"; export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { const { t } = useTranslation(); const { control, getValues } = useFormContext(); - - //const invoiceFormData = useWatch({ control }); - - /*const [invoice, setInvoice] = useState({ - items: [], - subtotal_amount: 0, - discount_percentage: 0, - discount_amount: 0, - taxable_amount: 0, - taxes_amount: 0, - total_amount: 0, - }); - - const updateDiscount = (value: number) => { - const subtotal = getValues('items.reduce( - (sum: number, item: any) => sum + item.subtotal_amount, - 0 - ); - const discountAmount = (subtotal * value) / 100; - const taxableAmount = subtotal - discountAmount; - const taxesAmount = taxableAmount * 0.21; // Mock calculation - const totalAmount = taxableAmount + taxesAmount; - - setInvoice({ - ...invoice, - subtotal_amount: subtotal, - discount_percentage: value, - discount_amount: discountAmount, - taxable_amount: taxableAmount, - taxes_amount: taxesAmount, - total_amount: totalAmount, - }); - };*/ - - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("es-ES", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount); - }; + const { currency_code, language_code } = useInvoiceContext(); return (
@@ -62,55 +23,56 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
- - {formatCurrency(getValues('subtotal_amount'))} + + {formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}
- +
()} + }) => ( + )} />
- + - -{formatCurrency(getValues("discount_amount"))} + -{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}
- +
- - {formatCurrency(getValues('taxable_amount'))} + + {formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
- - {formatCurrency(getValues('taxes_amount'))} + + {formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}
- +
- + - {formatCurrency(getValues('total_amount'))} + {formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
diff --git a/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx index cd9e04c9..47249aec 100644 --- a/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx @@ -1,6 +1,8 @@ +import { formatCurrency } from '@erp/core'; import { useMoney } from '@erp/core/hooks'; import { cn } from '@repo/shadcn-ui/lib/utils'; import * as React from "react"; +import { findFocusableInCell, focusAndSelect } from './input-utils'; import { InputEmptyMode, InputReadOnlyMode } from './quantity-input'; @@ -15,8 +17,8 @@ export type AmountInputProps = { emptyMode?: InputEmptyMode; // cómo presentar vacío emptyText?: string; // texto en vacío para value/placeholder scale?: number; // decimales; default 2 (ej. 4 para unit_amount) - locale?: string; // p.ej. "es-ES" - currency?: string; // p.ej. "EUR" + languageCode?: string; // p.ej. "es-ES" + currencyCode?: string; // p.ej. "EUR" className?: string; }; @@ -27,31 +29,24 @@ export function AmountInput({ readOnlyMode = "textlike-input", id, "aria-label": ariaLabel = "Amount", - step = 1.00, emptyMode = "blank", emptyText = "", scale = 2, - locale, - currency = "EUR", + languageCode = 'es', + currencyCode = "EUR", className, + ...inputProps }: AmountInputProps) { // Hook de dinero para parseo/redondeo consistente con el resto de la app - const { parse, roundToScale } = useMoney({ locale, fallbackCurrency: currency as any }); + const { parse, roundToScale } = useMoney({ locale: languageCode, fallbackCurrency: currencyCode as any }); const [raw, setRaw] = React.useState(""); const [focused, setFocused] = React.useState(false); const formatCurrencyNumber = React.useCallback( - (n: number) => - new Intl.NumberFormat(locale ?? undefined, { - style: "currency", - currency, - maximumFractionDigits: scale, - minimumFractionDigits: Number.isInteger(n) ? 0 : 0, - useGrouping: true, - }).format(n), - [locale, currency, scale] + (n: number) => formatCurrency(n, scale, currencyCode, languageCode), + [languageCode, currencyCode, scale] ); // Derivar texto visual desde prop `value` @@ -75,9 +70,12 @@ export function AmountInput({ if (!focused) setRaw(visualText); }, [visualText, focused]); - const handleChange = React.useCallback((e: React.ChangeEvent) => { - setRaw(e.currentTarget.value); - }, []); + const handleChange = React.useCallback( + (e: React.ChangeEvent) => { + setRaw(e.currentTarget.value); + }, + [] + ); const handleFocus = React.useCallback( (e: React.FocusEvent) => { @@ -89,7 +87,11 @@ export function AmountInput({ } const current = parse(e.currentTarget.value) ?? - (value === "" || value == null ? null : typeof value === "number" ? value : parse(String(value))); + (value === "" || value == null + ? null + : typeof value === "number" + ? value + : parse(String(value))); setRaw(current !== null && current !== undefined ? String(current) : ""); }, [emptyMode, emptyText, parse, value] @@ -118,17 +120,43 @@ export function AmountInput({ ); const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { + (e: React.KeyboardEvent) => { if (readOnly) return; - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + + const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + if (!keys.includes(e.key)) return; + 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, scale); - onChange(rounded); - setRaw(String(rounded)); // crudo durante edición + + const current = e.currentTarget as HTMLElement; + const rowIndex = Number(current.dataset.rowIndex); + const colIndex = Number(current.dataset.colIndex); + + let nextRow = rowIndex; + let nextCol = colIndex; + + switch (e.key) { + case "ArrowUp": + nextRow--; + break; + case "ArrowDown": + nextRow++; + break; + case "ArrowLeft": + nextCol--; + break; + case "ArrowRight": + nextCol++; + break; + } + + const nextElement = findFocusableInCell(nextRow, nextCol); + console.log(nextElement); + if (nextElement) { + focusAndSelect(nextElement); + } }, - [readOnly, parse, isShowingEmptyValue, raw, step, roundToScale, scale, onChange] + [readOnly] ); const handleBlock = React.useCallback((e: React.SyntheticEvent) => { @@ -152,6 +180,7 @@ export function AmountInput({ "focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default", className )} + {...inputProps} /> ); } @@ -177,6 +206,7 @@ export function AmountInput({ onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} + {...inputProps} /> ); } diff --git a/modules/customer-invoices/src/web/components/editor/items/blocks-view.tsx b/modules/customer-invoices/src/web/components/editor/items/blocks-view.tsx index 025878a2..2c5e64ce 100644 --- a/modules/customer-invoices/src/web/components/editor/items/blocks-view.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/blocks-view.tsx @@ -9,15 +9,6 @@ import { CustomItemViewProps } from "./types"; export interface BlocksViewProps extends CustomItemViewProps { } -const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("es-ES", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }).format(amount); -}; - export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => { const { t } = useTranslation(); const { control } = useFormContext(); diff --git a/modules/customer-invoices/src/web/components/editor/items/input-utils.ts b/modules/customer-invoices/src/web/components/editor/items/input-utils.ts new file mode 100644 index 00000000..dfd9e3fe --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/input-utils.ts @@ -0,0 +1,68 @@ +// Selectores típicos de elementos que son editables o permite foco +const FOCUSABLE_SELECTOR = [ + '[data-cell-focus]', // permite marcar manualmente el target dentro de la celda + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + '[contenteditable="true"]', + 'button:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])' +].join(','); + +// Busca el elemento focuseable dentro de la "celda" destino. +// Puedes poner data-row-index / data-col-index en la propia celda o en el control. +// Este helper cubre ambos casos. + +export function findFocusableInCell(row: number, col: number): HTMLElement | null { + // 1) ¿Hay un control que ya tenga los data-* directamente? + let el = + document.querySelector( + `[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith('[') ? '' : ''}` + ); + + // Si lo anterior no funcionó o seleccionó un contenedor, intenta: + if (!el) { + // 2) ¿Existe una celda contenedora (td/div) con esos data-*? + const cell = document.querySelector( + `[data-row-index="${row}"][data-col-index="${col}"]` + ); + if (!cell) return null; + + // 3) Dentro de la celda, busca el primer foco válido + el = cell.matches(FOCUSABLE_SELECTOR) ? cell : cell.querySelector(FOCUSABLE_SELECTOR); + } + + return el || null; +} + +// Da foco y selecciona contenido si procede. +export function focusAndSelect(el: HTMLElement) { + el.focus?.(); + + // Seleccionar tras el foco para evitar que el navegador cancele la selección + requestAnimationFrame(() => { + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + // Para inputs/textarea + try { + // select() funciona en la mayoría; si es type="number", cae en setSelectionRange + el.select?.(); + // Asegura selección completa si select() no aplica (p.ej. type="number") + if (typeof (el as any).setSelectionRange === 'function') { + const val = (el as any).value ?? ''; + (el as any).setSelectionRange(0, String(val).length); + } + } catch { + /* no-op */ + } + } else if ((el as HTMLElement).isContentEditable) { + // Para contenteditable + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + // Para select/button/otros focuseables no hacemos selección de texto. + }); +} \ 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 dbb27ac4..3abe5eeb 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 @@ -2,6 +2,7 @@ import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, Tooltip import { cn } from '@repo/shadcn-ui/lib/utils'; import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react"; import { Control, Controller } from "react-hook-form"; +import { useInvoiceContext } from '../../../context'; import { useTranslation } from '../../../i18n'; import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select'; import { AmountInputField } from './amount-input-field'; @@ -39,11 +40,12 @@ export const ItemRow = ({ onMoveDown, onRemove, }: ItemRowProps) => { const { t } = useTranslation(); + const { currency_code, language_code } = useInvoiceContext(); return ( {/* selección */} - +
{/* # */} - + {rowIndex + 1} {/* description */} - + )} /> {/* qty */} - + {/* unit */} - + {/* discount */} - + {/* taxes */} - + )} + data-cell-focus /> {/* total (solo lectura) */} - + {/* actions */} - +
{onDuplicate && ( @@ -163,6 +181,7 @@ export const ItemRow = ({ disabled={readOnly} aria-label='Duplicar fila' className='size-8 self-start -translate-y-[1px]' + data-cell-focus > @@ -180,6 +199,7 @@ export const ItemRow = ({ disabled={readOnly || isFirst} aria-label='Mover arriba' className='size-8 self-start -translate-y-[1px]' + data-cell-focus > @@ -194,6 +214,7 @@ export const ItemRow = ({ disabled={readOnly || isLast} aria-label='Mover abajo' className='size-8 self-start -translate-y-[1px]' + data-cell-focus > @@ -208,6 +229,7 @@ export const ItemRow = ({ disabled={readOnly} aria-label='Eliminar fila' className='size-8 self-start -translate-y-[1px]' + data-cell-focus > diff --git a/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx b/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx index de6e7cde..5fb83e56 100644 --- a/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx @@ -2,9 +2,10 @@ import { CheckedState, useRowSelection } from '@repo/rdx-ui/hooks'; import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components"; import { useCallback } from 'react'; import { useFormContext } from "react-hook-form"; -import { useItemsTableNavigation } from '../../../hooks'; +import { useInvoiceContext } from '../../../context'; +import { useInvoiceAutoRecalc, useItemsTableNavigation } from '../../../hooks'; import { useTranslation } from '../../../i18n'; -import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas'; +import { InvoiceFormData, InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas'; import { ItemRow } from './item-row'; import { ItemsEditorToolbar } from './items-editor-toolbar'; @@ -17,7 +18,9 @@ const createEmptyItem = () => defaultCustomerInvoiceItemFormData; export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => { const { t } = useTranslation(); - const form = useFormContext(); + const context = useInvoiceContext(); + const form = useFormContext(); + const { control } = form; // Navegación y operaciones sobre las filas const tableNav = useItemsTableNavigation(form, { @@ -26,7 +29,6 @@ export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => { firstEditableField: "description", }); - const { control } = form; const { fieldArray: { fields } } = tableNav; const { @@ -38,6 +40,8 @@ export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => { clearSelection, } = useRowSelection(fields.length); + useInvoiceAutoRecalc(form, context); + const handleAddSelection = useCallback(() => { if (readOnly) return; tableNav.addEmpty(true); diff --git a/modules/customer-invoices/src/web/components/editor/items/percentage-input.tsx b/modules/customer-invoices/src/web/components/editor/items/percentage-input.tsx index 36ffb5cb..fdc7cd6e 100644 --- a/modules/customer-invoices/src/web/components/editor/items/percentage-input.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/percentage-input.tsx @@ -1,5 +1,6 @@ import { cn } from '@repo/shadcn-ui/lib/utils'; import * as React from "react"; +import { findFocusableInCell, focusAndSelect } from './input-utils'; import { InputEmptyMode, InputReadOnlyMode } from './quantity-input'; export type PercentageInputProps = { @@ -36,6 +37,7 @@ export function PercentageInput({ showSuffix = true, locale, className, + ...inputProps }: PercentageInputProps) { const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim(); @@ -148,18 +150,43 @@ export function PercentageInput({ ); const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { + (e: React.KeyboardEvent) => { if (readOnly) return; - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + + const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + if (!keys.includes(e.key)) return; + e.preventDefault(); - const base = parseLocaleNumber(isShowingEmptyValue ? "" : raw) ?? 0; - const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1); - const next = clamp(base + delta); - const rounded = roundToScale(next, scale); - onChange(rounded); - setRaw(String(rounded)); // crudo durante edición + + const current = e.currentTarget as HTMLElement; + const rowIndex = Number(current.dataset.rowIndex); + const colIndex = Number(current.dataset.colIndex); + + let nextRow = rowIndex; + let nextCol = colIndex; + + switch (e.key) { + case "ArrowUp": + nextRow--; + break; + case "ArrowDown": + nextRow++; + break; + case "ArrowLeft": + nextCol--; + break; + case "ArrowRight": + nextCol++; + break; + } + + const nextElement = findFocusableInCell(nextRow, nextCol); + console.log(nextElement); + if (nextElement) { + focusAndSelect(nextElement); + } }, - [readOnly, parseLocaleNumber, isShowingEmptyValue, raw, step, clamp, roundToScale, scale, onChange] + [readOnly] ); // Bloquear foco/edición en modo texto @@ -185,6 +212,7 @@ export function PercentageInput({ "focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default", className )} + {...inputProps} /> ); } @@ -213,6 +241,7 @@ export function PercentageInput({ onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} + {...inputProps} /> ); } diff --git a/modules/customer-invoices/src/web/components/editor/items/quantity-input.tsx b/modules/customer-invoices/src/web/components/editor/items/quantity-input.tsx index 95dfdea4..8b31898a 100644 --- a/modules/customer-invoices/src/web/components/editor/items/quantity-input.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/quantity-input.tsx @@ -4,6 +4,7 @@ import { useQuantity } from '@erp/core/hooks'; import { cn } from "@repo/shadcn-ui/lib/utils"; import * as React from "react"; +import { findFocusableInCell, focusAndSelect } from './input-utils'; export type InputEmptyMode = "blank" | "placeholder" | "value"; @@ -17,7 +18,6 @@ export type QuantityInputProps = { readOnlyMode?: InputReadOnlyMode; id?: string; "aria-label"?: string; - step?: number; // default 1 emptyMode?: InputEmptyMode; // cómo presentar vacío emptyText?: string; // texto de vacío para value-mode/placeholder scale?: number; // default 2 @@ -36,7 +36,6 @@ export function QuantityInput({ readOnlyMode = "textlike-input", id, "aria-label": ariaLabel = "Quantity", - step = 1, emptyMode = "blank", emptyText = "", scale = 2, @@ -44,6 +43,7 @@ export function QuantityInput({ className, displaySuffix, nbspBeforeSuffix = true, + ...inputProps }: QuantityInputProps) { const { parse, roundToScale } = useQuantity({ defaultScale: scale }); const [raw, setRaw] = React.useState(""); @@ -76,11 +76,14 @@ export function QuantityInput({ typeof value === "number" ? value : (parse(String(value)) ?? Number(String(value).replaceAll(",", ""))); // tolera string numérico + if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : ""; const n = roundToScale(numeric, scale); const numTxt = formatNumber(n); const suf = suffixFor(n); - return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt; + return suf + ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` + : numTxt; }, [value, emptyMode, emptyText, parse, roundToScale, scale, formatNumber, suffixFor, nbspBeforeSuffix]); const isShowingEmptyValue = emptyMode === "value" && raw === emptyText; @@ -99,6 +102,7 @@ export function QuantityInput({ const handleFocus = React.useCallback( (e: React.FocusEvent) => { + setFocused(true); if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); @@ -120,6 +124,7 @@ export function QuantityInput({ (e: React.FocusEvent) => { setFocused(false); const txt = e.currentTarget.value.trim(); + if (txt === "" || isShowingEmptyValue) { onChange(""); setRaw(emptyMode === "value" ? emptyText : ""); @@ -141,17 +146,43 @@ export function QuantityInput({ ); const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { + (e: React.KeyboardEvent) => { if (readOnly) return; - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + + const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + if (!keys.includes(e.key)) return; + 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, scale); - onChange(rounded); - setRaw(String(rounded)); // crudo durante edición + + const current = e.currentTarget as HTMLElement; + const rowIndex = Number(current.dataset.rowIndex); + const colIndex = Number(current.dataset.colIndex); + + let nextRow = rowIndex; + let nextCol = colIndex; + + switch (e.key) { + case "ArrowUp": + nextRow--; + break; + case "ArrowDown": + nextRow++; + break; + case "ArrowLeft": + nextCol--; + break; + case "ArrowRight": + nextCol++; + break; + } + + const nextElement = findFocusableInCell(nextRow, nextCol); + console.log(nextElement); + if (nextElement) { + focusAndSelect(nextElement); + } }, - [readOnly, parse, isShowingEmptyValue, raw, step, roundToScale, scale, onChange] + [readOnly] ); // ── READ-ONLY como input que parece texto ─────────────────────────────── @@ -179,6 +210,7 @@ export function QuantityInput({ "focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default", className )} + {...inputProps} /> ); } @@ -205,6 +237,7 @@ export function QuantityInput({ onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} + {...inputProps} /> ); } diff --git a/modules/customer-invoices/src/web/domain/calculate-invoice-header-amounts.ts b/modules/customer-invoices/src/web/domain/calculate-invoice-header-amounts.ts index e70b025a..5724239a 100644 --- a/modules/customer-invoices/src/web/domain/calculate-invoice-header-amounts.ts +++ b/modules/customer-invoices/src/web/domain/calculate-invoice-header-amounts.ts @@ -1,17 +1,24 @@ -import Dinero, { Currency } from "dinero.js"; +import { TaxItemType } from "@erp/core"; +import type { Dinero } from "dinero.js"; +import { InvoiceItemTaxSummary } from "./calculate-invoice-item-amounts"; +import { toDinero } from "./calculate-utils"; export interface InvoiceHeaderCalcInput { subtotal_amount: number; discount_amount: number; + header_discount_amount: number; taxable_amount: number; taxes_amount: number; + taxes_summary: InvoiceItemTaxSummary[]; total_amount: number; } export interface InvoiceHeaderCalcResult { subtotal_amount: number; discount_amount: number; + header_discount_amount: number; taxable_amount: number; + taxes_summary: InvoiceItemTaxSummary[]; taxes_amount: number; total_amount: number; } @@ -24,26 +31,24 @@ export function calculateInvoiceHeaderAmounts( items: InvoiceHeaderCalcInput[], currency: string ): InvoiceHeaderCalcResult { - const scale = 2; - const toDinero = (n: number) => - Dinero({ - amount: n === 0 ? 0 : Math.round(n * 10 ** scale), - precision: scale, - currency: currency as Currency, - }); + const defaultScale = 2; - let subtotal = toDinero(0); - let discount = toDinero(0); - let taxable = toDinero(0); - let taxes = toDinero(0); - let total = toDinero(0); + let subtotal = toDinero(0, defaultScale, currency); + let discount = toDinero(0, defaultScale, currency); + let header_discount = toDinero(0, defaultScale, currency); + let taxable = toDinero(0, defaultScale, currency); + let taxes = toDinero(0, defaultScale, currency); + let total = toDinero(0, defaultScale, currency); + const taxes_summary: InvoiceItemTaxSummary[] = []; for (const item of items) { - subtotal = subtotal.add(toDinero(item.subtotal_amount)); - discount = discount.add(toDinero(item.discount_amount)); - taxable = taxable.add(toDinero(item.taxable_amount)); - taxes = taxes.add(toDinero(item.taxes_amount)); - total = total.add(toDinero(item.total_amount)); + subtotal = subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency)); + discount = discount.add(toDinero(item.discount_amount, defaultScale, currency)); + header_discount = header_discount.add(toDinero(item.discount_amount, defaultScale, currency)); + taxable = taxable.add(toDinero(item.taxable_amount, defaultScale, currency)); + taxes = taxes.add(toDinero(item.taxes_amount, defaultScale, currency)); + total = total.add(toDinero(item.total_amount, defaultScale, currency)); + taxes_summary.push(...item.taxes_summary); } const toNum = (d: Dinero.Dinero) => d.toUnit(); @@ -51,8 +56,53 @@ export function calculateInvoiceHeaderAmounts( return { subtotal_amount: toNum(subtotal), discount_amount: toNum(discount), + header_discount_amount: toNum(header_discount), taxable_amount: toNum(taxable), taxes_amount: toNum(taxes), total_amount: toNum(total), + taxes_summary: calculateTaxesSummary(taxes_summary, currency), }; } + +function calculateTaxesSummary( + items_summary: InvoiceItemTaxSummary[], + currency: string +): InvoiceItemTaxSummary[] { + const defaultScale = 2; + const summaryMap = new Map< + string, + { tax: TaxItemType; taxable_amount: Dinero; taxes_amount: Dinero } + >(); + + for (const item of items_summary) { + const { taxable_amount, taxes_amount, ...tax } = item; + const key = tax.code; + + const current = summaryMap.get(key) ?? { + tax, + taxable_amount: toDinero(0, defaultScale, currency), + taxes_amount: toDinero(0, defaultScale, currency), + }; + + summaryMap.set(key, { + tax: current.tax, + taxable_amount: current.taxable_amount.add(toDinero(taxable_amount, defaultScale, currency)), + taxes_amount: current.taxes_amount.add(toDinero(taxes_amount, defaultScale, currency)), + }); + } + + // Convertimos el mapa en un array con números desescalados + const result: InvoiceItemTaxSummary[] = []; + + for (const { tax, taxable_amount, taxes_amount } of summaryMap.values()) { + result.push({ + ...tax, + taxable_amount: taxable_amount.toUnit(), + taxes_amount: taxes_amount.toUnit(), + }); + } + + // Los devolvermos ordenador: primero los que suman, + // luego los que restan: IVA, IGIC, IPSI, Recargo de equivalencia, Retención. + return result.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/modules/customer-invoices/src/web/domain/calculate-invoice-item-amounts.ts b/modules/customer-invoices/src/web/domain/calculate-invoice-item-amounts.ts index 8e1ec0bf..e49154cd 100644 --- a/modules/customer-invoices/src/web/domain/calculate-invoice-item-amounts.ts +++ b/modules/customer-invoices/src/web/domain/calculate-invoice-item-amounts.ts @@ -1,18 +1,26 @@ -import { TaxCatalogProvider } from "@erp/core"; -import Dinero, { Currency } from "dinero.js"; +import { TaxCatalogProvider, TaxItemType } from "@erp/core"; +import { toDinero, toNum } from "./calculate-utils"; export interface InvoiceItemCalcInput { quantity?: string; // p.ej. "3.5" unit_amount?: string; // p.ej. "125.75" discount_percentage?: string; // p.ej. "10" (=> 10%) + header_discount_percentage?: string; // p.ej. "5" (=> 5%) tax_codes: string[]; // ["iva_21", ...] } +export type InvoiceItemTaxSummary = TaxItemType & { + taxable_amount: number; + taxes_amount: number; +}; + export interface InvoiceItemCalcResult { subtotal_amount: number; discount_amount: number; + header_discount_amount: number; taxable_amount: number; taxes_amount: number; + taxes_summary: InvoiceItemTaxSummary[]; total_amount: number; } @@ -25,48 +33,65 @@ export function calculateInvoiceItemAmounts( currency: string, taxCatalog: TaxCatalogProvider ): InvoiceItemCalcResult { - const scale = 4; - const toDinero = (n: number) => - Dinero({ - amount: n === 0 ? 0 : Math.round(n * 10 ** scale), - precision: scale, - currency: currency as Currency, - }); + const defaultScale = 4; + const taxesSummary: InvoiceItemTaxSummary[] = []; const qty = Number.parseFloat(item.quantity || "0") || 0; const unit = Number.parseFloat(item.unit_amount || "0") || 0; - const pct = Number.parseFloat(item.discount_percentage || "0") || 0; + const iten_pct = Number.parseFloat(item.discount_percentage || "0") || 0; + const header_pct = Number.parseFloat(item.header_discount_percentage || "0") || 0; // Subtotal = cantidad × precio unitario - const subtotal = toDinero(qty * unit); + const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty); - // Descuento = subtotal × (pct / 100) - const discount = subtotal.percentage(pct); + // Descuento = subtotal × (item_pct / 100) + const discount_amount = subtotal_amount.percentage(iten_pct); + const subtotal_w_discount_amount = subtotal_amount.subtract(discount_amount); + + // Descuento de la cabecera = subtotal con dto de línea × (header_pct / 100) + const header_discount = subtotal_w_discount_amount.percentage(header_pct); // Base imponible - const taxable = subtotal.subtract(discount); + const taxable_amount = subtotal_w_discount_amount.subtract(header_discount); - // Impuestos acumulados - let taxes = toDinero(0); + // Impuestos acumulados con signo + let taxes_amount = toDinero(0, defaultScale, currency); for (const code of item.tax_codes ?? []) { const tax = taxCatalog.findByCode(code); + if (tax.isNone()) continue; + tax.map((taxItem) => { - const pctValue = Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10); - const taxAmount = taxable.percentage(pctValue); - taxes = taxes.add(taxAmount); + const tax_pct_value = + Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10); + const item_taxables_amount = taxable_amount.percentage(tax_pct_value); + + // Sumar o restar según grupo + switch (taxItem.group.toLowerCase()) { + case "retención": + taxes_amount = taxes_amount.subtract(item_taxables_amount); + break; + default: + taxes_amount = taxes_amount.add(item_taxables_amount); + break; + } + + taxesSummary.push({ + ...taxItem, + taxable_amount: toNum(taxable_amount), + taxes_amount: toNum(item_taxables_amount), + }); }); } - const total = taxable.add(taxes); - - // Devuelve valores desescalados (número con 2 decimales exactos) - const toNum = (d: Dinero.Dinero) => d.toUnit(); + const total = taxable_amount.add(taxes_amount); return { - subtotal_amount: toNum(subtotal), - discount_amount: toNum(discount), - taxable_amount: toNum(taxable), - taxes_amount: toNum(taxes), + subtotal_amount: toNum(subtotal_amount), + discount_amount: toNum(discount_amount), + header_discount_amount: toNum(header_discount), + taxable_amount: toNum(taxable_amount), + taxes_amount: toNum(taxes_amount), + taxes_summary: taxesSummary, total_amount: toNum(total), }; } diff --git a/modules/customer-invoices/src/web/domain/calculate-utils.ts b/modules/customer-invoices/src/web/domain/calculate-utils.ts new file mode 100644 index 00000000..df035bd9 --- /dev/null +++ b/modules/customer-invoices/src/web/domain/calculate-utils.ts @@ -0,0 +1,12 @@ +import DineroFactory, { Currency } from "dinero.js"; + +// Función auxiliar para convertir a Dinero +export const toDinero = (n: number, scale: number, currency: string) => + DineroFactory({ + amount: n === 0 ? 0 : Math.round(n * 10 ** scale), + precision: scale, + currency: currency as Currency, + }); + +// Función auxiliar que devuelve el valor de Dinero +export const toNum = (d: DineroFactory.Dinero) => d.toUnit(); 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 index 2b67723b..268dab0d 100644 --- 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 @@ -1,5 +1,5 @@ import { TaxCatalogProvider } from "@erp/core"; -import * as React from "react"; +import React from "react"; import { UseFormReturn } from "react-hook-form"; import { InvoiceItemCalcResult, @@ -24,8 +24,6 @@ export function useInvoiceAutoRecalc( ) { const { watch, - setValue, - getValues, trigger, formState: { isDirty, isLoading, isSubmitting }, } = form; @@ -37,18 +35,9 @@ export function useInvoiceAutoRecalc( new Map() ); - // Cache de los totales de la factura - const invoiceTotalsRef = React.useRef>({ - subtotal_amount: 0, - discount_amount: 0, - taxable_amount: 0, - taxes_amount: 0, - total_amount: 0, - }); - // Cálculo de una línea (usa dominio puro) const calculateItemTotals = React.useCallback( - (item: InvoiceItemFormData) => { + (item: InvoiceItemFormData, header_discount_percentage: number) => { const sanitizeString = (v?: number | string) => v && !Number.isNaN(Number(v)) ? String(v) : "0"; @@ -57,6 +46,7 @@ export function useInvoiceAutoRecalc( quantity: sanitizeString(item.quantity), unit_amount: sanitizeString(item.unit_amount), discount_percentage: sanitizeString(item.discount_percentage), + header_discount_percentage: sanitizeString(header_discount_percentage), tax_codes: item.tax_codes, }, currency_code, @@ -66,40 +56,21 @@ export function useInvoiceAutoRecalc( [taxCatalog, currency_code] ); - // Recalculo incremental de cabecera - const recalcInvoiceTotalsIncrementally = React.useCallback( - ( - prevTotals: ReturnType, - prevItem?: ReturnType, - newItem?: ReturnType - ): ReturnType => { - const adjust = (field: keyof typeof prevTotals) => - prevTotals[field] - (prevItem?.[field] ?? 0) + (newItem?.[field] ?? 0); - - return { - 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"), - }; - }, - [] - ); - // Totales globales (usa funciones del dominio) const calculateInvoiceTotals = React.useCallback( - (items: InvoiceItemFormData[]) => { + (items: InvoiceItemFormData[], header_discount_percentage: number) => { const lines = items .filter((i) => !i.is_non_valued) .map((i) => { - const totals = calculateItemTotals(i); + const itemTotals = calculateItemTotals(i, header_discount_percentage); 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, + subtotal_amount: itemTotals.subtotal_amount, + discount_amount: itemTotals.discount_amount, + header_discount_amount: itemTotals.header_discount_amount, + taxable_amount: itemTotals.taxable_amount, + taxes_amount: itemTotals.taxes_amount, + total_amount: itemTotals.total_amount, + taxes_summary: itemTotals.taxes_summary, }; }); @@ -108,7 +79,7 @@ export function useInvoiceAutoRecalc( [calculateItemTotals, currency_code] ); - // Suscripción reactiva a cambios del formulario + // Observamos el formulario esperando cualquier cambio React.useEffect(() => { console.log("recalculo algo?"); @@ -116,19 +87,27 @@ export function useInvoiceAutoRecalc( const subscription = watch((formData, { name, type }) => { console.log(name, type); + const items = (formData?.items || []) as InvoiceItemFormData[]; + const header_discount_percentage = formData?.discount_percentage || 0; if (items.length === 0) return; // Detectar cambios en la cabecera if (name === "discount_percentage") { // Recalcular totales de factura - const invoiceTotals = calculateInvoiceTotals(items); + const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage); - // Cabecera + // Estableer valores en cabecera setInvoiceTotals(form, invoiceTotals); - // 3) valida una vez (opcional) + // Forzar actualización de todas las líneas + items.forEach((item, idx) => { + const newTotals = calculateItemTotals(item, header_discount_percentage); + itemCache.current.set(idx, newTotals); + setInvoiceItemTotals(form, idx, newTotals); + }); + trigger([ "subtotal_amount", "discount_amount", @@ -149,13 +128,17 @@ export function useInvoiceAutoRecalc( console.log("2.1. recalculo items!"); const item = items[index] as InvoiceItemFormData; const prevTotals = itemCache.current.get(index); - const newTotals = calculateItemTotals(item); - - console.log(prevTotals, newTotals); + const newTotals = calculateItemTotals(item, header_discount_percentage); // Si no hay cambios en los totales, no tocamos nada const itemHasChanges = - prevTotals || JSON.stringify(prevTotals) !== JSON.stringify(newTotals); + !prevTotals || JSON.stringify(prevTotals) !== JSON.stringify(newTotals); + + console.log( + JSON.stringify(prevTotals), + JSON.stringify(newTotals), + itemHasChanges ? "hay cambios" : "no hay cambios" + ); if (!itemHasChanges) { console.log("No hay cambios, me voy!!!"); @@ -169,19 +152,13 @@ export function useInvoiceAutoRecalc( 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; + const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage); console.log(invoiceTotals); - // Cabecera + // Estableer valores en cabecera setInvoiceTotals(form, invoiceTotals); - // 3) valida una vez (opcional) trigger([ "items", "subtotal_amount", @@ -196,16 +173,12 @@ export function useInvoiceAutoRecalc( return () => subscription.unsubscribe(); }, [ + form, watch, trigger, - setValue, - getValues, isDirty, isLoading, isSubmitting, - itemCache, - setInvoiceItemTotals, - setInvoiceTotals, calculateItemTotals, calculateInvoiceTotals, ]); @@ -252,7 +225,7 @@ function setInvoiceTotals( shouldDirty: true, shouldValidate: false, }); - setValue("discount_amount", invoiceTotals.discount_amount, { + setValue("discount_amount", invoiceTotals.header_discount_amount, { shouldDirty: true, shouldValidate: false, }); @@ -268,4 +241,14 @@ function setInvoiceTotals( shouldDirty: true, shouldValidate: false, }); + + setValue( + "taxes", + invoiceTotals.taxes_summary.map((tax_item) => ({ + tax_code: tax_item.code, + tax_label: tax_item.name, + taxable_amount: tax_item.taxable_amount, + taxes_amount: tax_item.taxes_amount, + })) + ); } diff --git a/modules/customer-invoices/src/web/hooks/use-items-table-navigation.ts b/modules/customer-invoices/src/web/hooks/use-items-table-navigation.ts index ad5d317b..265f07dc 100644 --- a/modules/customer-invoices/src/web/hooks/use-items-table-navigation.ts +++ b/modules/customer-invoices/src/web/hooks/use-items-table-navigation.ts @@ -1,18 +1,25 @@ import * as React from "react"; -import { FieldValues, UseFormReturn, useFieldArray } from "react-hook-form"; +import { FieldValues, Path, UseFormReturn, useFieldArray } from "react-hook-form"; -export interface UseItemsTableNavigationOptions { - name: string; - createEmpty: () => Record; +interface UseItemsTableNavigationOptions { + /** Nombre del array de líneas en el formulario (tipo-safe) */ + name: Path; + /** Creador de una línea vacía */ + createEmpty: () => unknown; // ajusta el tipo del item si lo conoces + /** Primer campo editable de la fila */ firstEditableField?: string; } -export function useItemsTableNavigation( - form: UseFormReturn, - { name, createEmpty, firstEditableField = "description" }: UseItemsTableNavigationOptions +export function useItemsTableNavigation( + form: UseFormReturn, + { + name, + createEmpty, + firstEditableField = "description", + }: UseItemsTableNavigationOptions ) { const { control, getValues, setFocus } = form; - const fa = useFieldArray({ control, name }); + const fa = useFieldArray({ control, name }); // Desestructurar para evitar recreaciones const { append, insert, remove: faRemove, move } = fa; diff --git a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx index 89363715..cedfaec6 100644 --- a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx +++ b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx @@ -14,7 +14,7 @@ import { PageHeader } from "../../components"; import { useInvoiceContext } from '../../context'; -import { useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks"; +import { useUpdateCustomerInvoice } from "../../hooks"; import { useTranslation } from "../../i18n"; import { CustomerInvoice, @@ -58,7 +58,6 @@ export const InvoiceUpdateComp = ({ disabled: !invoiceData || isUpdating }); - useInvoiceAutoRecalc(form, context); const handleSubmit = (formData: InvoiceFormData) => { mutate( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edfc930a..a5aa7af1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,6 +556,9 @@ importers: '@hookform/devtools': specifier: ^4.4.0 version: 4.4.0(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/dinero.js': + specifier: ^1.9.4 + version: 1.9.4 '@types/express': specifier: ^4.17.21 version: 4.17.23