Facturas de cliente
This commit is contained in:
parent
27a5e30d37
commit
d971411757
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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.
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
12
modules/customer-invoices/src/web/domain/calculate-utils.ts
Normal file
12
modules/customer-invoices/src/web/domain/calculate-utils.ts
Normal 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();
|
||||
@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user