Facturas de cliente

This commit is contained in:
David Arranz 2025-10-13 19:41:21 +02:00
parent 27a5e30d37
commit d971411757
20 changed files with 508 additions and 281 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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<InvoiceFormData>();
console.log("CustomerInvoiceEditForm")
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={cn("space-y-6", className)}>
<div className='w-full'>
<FormDebug />
</div>
<div className="mx-auto grid w-full grid-cols-1 grid-flow-col gap-6 lg:grid-cols-2 items-stretch">
<div className="lg:col-start-1 lg:row-span-2 h-full">
@ -45,21 +43,20 @@ export const CustomerInvoiceEditForm = ({
<InvoiceNotes className="h-full flex flex-col" />
</div>
<div className="lg:col-start-1 lg:col-span-full h-full">
<InvoiceItems className="h-full flex flex-col" />
</div>
<div className="h-full lg:col-start-1">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceTotals className="h-full flex flex-col" />
</div>
</div>
<div className="h-full lg:col-start-1">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceTotals className="h-full flex flex-col" />
</div>
</section>
</form>
);

View File

@ -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<InvoiceFormData>();
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">) => {
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
{displayTaxes.map((tax, index) => (
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2'>
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2 text-base '>
<div className='flex items-center justify-between mb-2 '>
<Badge variant='secondary' className='text-xs'>
<Badge variant='secondary' className='text-sm font-semibold'>
{tax.tax_label}
</Badge>
</div>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Base para el impuesto:</span>
<span className='font-medium tabular-nums'>{formatCurrency(tax.taxable_amount)}</span>
<span className='text-current'>Base para el impuesto:</span>
<span className='text-base text-current tabular-nums'>{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Importe de impuesto:</span>
<span className='font-medium text-primary tabular-nums'>
{formatCurrency(tax.taxes_amount)}
<span className='text-current font-semibold'>Importe de impuesto:</span>
<span className='text-base text-current font-semibold tabular-nums'>
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
</div>

View File

@ -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<InvoiceFormData>();
//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 (
<Fieldset {...props}>
@ -62,55 +23,56 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Subtotal</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'))}</span>
<Label className='text-sm'>Subtotal</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
</div>
<div className='flex justify-between items-center gap-4'>
<Label className='text-sm text-muted-foreground'>Descuento (%)</Label>
<Label className='text-sm'>Descuento global (%)</Label>
<div className='flex items-center gap-2'>
<Controller
control={control}
name={"discount_percentage"}
render={({
field, fieldState
}) => (<Input
readOnly={false}
value={field.value}
onChange={field.onChange}
disabled={fieldState.isValidating}
onBlur={field.onBlur}
className='w-20 text-right'
/>)}
}) => (
<Input
readOnly={false}
value={field.value}
onChange={field.onChange}
disabled={fieldState.isValidating}
onBlur={field.onBlur}
className='w-20 text-right'
/>)}
/>
</div>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Importe del descuento</Label>
<Label className='text-sm'>Importe del descuento</Label>
<span className='font-medium text-destructive tabular-nums'>
-{formatCurrency(getValues("discount_amount"))}
-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}
</span>
</div>
<Separator />
<Separator className='bg-muted-foreground' />
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Base imponible</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'))}</span>
<Label className='text-sm'>Base imponible</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}</span>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Total de impuestos</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'))}</span>
<Label className='text-sm'>Total de impuestos</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
</div>
<Separator />
<Separator className='bg-muted-foreground h-0.5' />
<div className='flex justify-between items-center'>
<Label className='text-lg font-semibold'>Total de la factura</Label>
<Label className='text-xl font-semibold'>Total de la factura</Label>
<span className='text-xl font-bold text-primary tabular-nums'>
{formatCurrency(getValues('total_amount'))}
{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
</span>
</div>
</div>

View File

@ -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<string>("");
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<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
},
[]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
@ -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<HTMLInputElement>) => {
(e: React.KeyboardEvent<HTMLElement>) => {
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<HTMLInputElement>) => {
@ -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}
/>
);
}

View File

@ -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<InvoiceFormData>();

View File

@ -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 <td> 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<HTMLElement>(
`[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<HTMLElement>(
`[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<HTMLElement>(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.
});
}

View File

@ -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 (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className='align-top'>
<TableCell className='align-top' data-col-index={1}>
<div className='h-5'>
<Checkbox
aria-label={`Seleccionar fila ${rowIndex + 1}`}
@ -51,19 +53,20 @@ export const ItemRow = ({
checked={isSelected}
onCheckedChange={onToggleSelect}
disabled={readOnly}
data-cell-focus
/>
</div>
</TableCell>
{/* # */}
<TableCell className='text-left pt-[6px]'>
<TableCell className='text-left pt-[6px]' data-col-index={2}>
<span className='block translate-y-[-1px] text-muted-foreground tabular-nums text-xs'>
{rowIndex + 1}
</span>
</TableCell>
{/* description */}
<TableCell>
<TableCell data-col-index={3}>
<Controller
control={control}
name={`items.${rowIndex}.description`}
@ -83,47 +86,58 @@ export const ItemRow = ({
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
data-cell-focus
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className='text-right'>
<TableCell className='text-right' data-col-index={4}>
<QuantityInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
inputId={`quantity-${rowIndex}`}
emptyMode="blank"
data-row-index={rowIndex}
data-col-index={4}
data-cell-focus
/>
</TableCell>
{/* unit */}
<TableCell className='text-right'>
<TableCell className='text-right' data-col-index={5}>
<AmountInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
inputId={`unit-amount-${rowIndex}`}
scale={4}
locale={"es"}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={rowIndex}
data-col-index={5}
data-cell-focus
/>
</TableCell>
{/* discount */}
<TableCell className='text-right'>
<TableCell className='text-right' data-col-index={6}>
<PercentageInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
inputId={`discount-percentage-${rowIndex}`}
showSuffix
data-row-index={rowIndex}
data-col-index={6}
data-cell-focus
/>
</TableCell>
{/* taxes */}
<TableCell>
<TableCell data-col-index={7}>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
@ -131,26 +145,30 @@ export const ItemRow = ({
<CustomerInvoiceTaxesMultiSelect
value={field.value}
onChange={field.onChange}
data-row-index={rowIndex}
data-col-index={7}
/>
)}
data-cell-focus
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<TableCell className='text-right tabular-nums pt-[6px] leading-5' data-col-index={8}>
<HoverCardTotalsSummary rowIndex={rowIndex} >
<AmountInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
locale="es"
currencyCode={currency_code}
languageCode={language_code}
/>
</HoverCardTotalsSummary>
</TableCell>
{/* actions */}
<TableCell className='pt-[4px]'>
<TableCell className='pt-[4px]' data-col-index={9}>
<div className='flex justify-end gap-0'>
{onDuplicate && (
<Tooltip>
@ -163,6 +181,7 @@ export const ItemRow = ({
disabled={readOnly}
aria-label='Duplicar fila'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<CopyIcon className='size-4' />
</Button>
@ -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
>
<ArrowUpIcon className='size-4' />
</Button>
@ -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
>
<ArrowDownIcon className='size-4' />
</Button>
@ -208,6 +229,7 @@ export const ItemRow = ({
disabled={readOnly}
aria-label='Eliminar fila'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<Trash2Icon className='size-4' />
</Button>

View File

@ -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<InvoiceFormData>();
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);

View File

@ -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<HTMLInputElement>) => {
(e: React.KeyboardEvent<HTMLElement>) => {
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}
/>
);
}

View File

@ -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<string>("");
@ -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<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
@ -120,6 +124,7 @@ export function QuantityInput({
(e: React.FocusEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
(e: React.KeyboardEvent<HTMLElement>) => {
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}
/>
);
}

View File

@ -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));
}

View File

@ -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),
};
}

View File

@ -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();

View File

@ -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<ReturnType<typeof calculateInvoiceHeaderAmounts>>({
subtotal_amount: 0,
discount_amount: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
});
// Cálculo de una línea (usa dominio puro)
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<typeof calculateInvoiceHeaderAmounts>,
prevItem?: ReturnType<typeof calculateInvoiceItemAmounts>,
newItem?: ReturnType<typeof calculateInvoiceItemAmounts>
): ReturnType<typeof calculateInvoiceHeaderAmounts> => {
const adjust = (field: keyof typeof prevTotals) =>
prevTotals[field] - (prevItem?.[field] ?? 0) + (newItem?.[field] ?? 0);
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,
}))
);
}

View File

@ -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<string, unknown>;
interface UseItemsTableNavigationOptions<TFieldValues extends FieldValues> {
/** Nombre del array de líneas en el formulario (tipo-safe) */
name: Path<TFieldValues>;
/** 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<FieldValues>,
{ name, createEmpty, firstEditableField = "description" }: UseItemsTableNavigationOptions
export function useItemsTableNavigation<TFieldValues extends FieldValues = FieldValues>(
form: UseFormReturn<TFieldValues>,
{
name,
createEmpty,
firstEditableField = "description",
}: UseItemsTableNavigationOptions<TFieldValues>
) {
const { control, getValues, setFocus } = form;
const fa = useFieldArray({ control, name });
const fa = useFieldArray<TFieldValues>({ control, name });
// Desestructurar para evitar recreaciones
const { append, insert, remove: faRemove, move } = fa;

View File

@ -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(

View File

@ -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