Facturas de cliente
This commit is contained in:
parent
27a5e30d37
commit
d971411757
@ -2,6 +2,21 @@
|
|||||||
* Funciones para manipular valores monetarios numéricos.
|
* 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.
|
* Elimina símbolos de moneda y caracteres no numéricos.
|
||||||
* @param s Texto de entrada, e.g. "€ 1.234,56"
|
* @param s Texto de entrada, e.g. "€ 1.234,56"
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/devtools": "^4.4.0",
|
"@hookform/devtools": "^4.4.0",
|
||||||
|
"@types/dinero.js": "^1.9.4",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.3",
|
"@types/react-dom": "^19.1.3",
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { FieldErrors, useFormContext } from "react-hook-form";
|
import { FieldErrors, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { FormDebug } from "@erp/core/components";
|
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { InvoiceFormData } from "../../schemas";
|
import { InvoiceFormData } from "../../schemas";
|
||||||
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
|
||||||
import { InvoiceItems } from './invoice-items-editor';
|
import { InvoiceItems } from './invoice-items-editor';
|
||||||
import { InvoiceNotes } from './invoice-tax-notes';
|
import { InvoiceNotes } from './invoice-notes';
|
||||||
import { InvoiceTaxSummary } from './invoice-tax-summary';
|
import { InvoiceTaxSummary } from './invoice-tax-summary';
|
||||||
import { InvoiceTotals } from './invoice-totals';
|
import { InvoiceTotals } from './invoice-totals';
|
||||||
import { InvoiceRecipient } from "./recipient";
|
import { InvoiceRecipient } from "./recipient";
|
||||||
@ -25,12 +24,11 @@ export const CustomerInvoiceEditForm = ({
|
|||||||
}: CustomerInvoiceFormProps) => {
|
}: CustomerInvoiceFormProps) => {
|
||||||
const form = useFormContext<InvoiceFormData>();
|
const form = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
console.log("CustomerInvoiceEditForm")
|
|
||||||
return (
|
return (
|
||||||
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
||||||
<section className={cn("space-y-6", className)}>
|
<section className={cn("space-y-6", className)}>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<FormDebug />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto grid w-full grid-cols-1 grid-flow-col gap-6 lg:grid-cols-2 items-stretch">
|
<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">
|
<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" />
|
<InvoiceNotes className="h-full flex flex-col" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="lg:col-start-1 lg:col-span-full h-full">
|
<div className="lg:col-start-1 lg:col-span-full h-full">
|
||||||
<InvoiceItems className="h-full flex flex-col" />
|
<InvoiceItems 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>
|
||||||
</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>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
|
import { formatCurrency } from '@erp/core';
|
||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
||||||
import { Badge } from "@repo/shadcn-ui/components";
|
import { Badge } from "@repo/shadcn-ui/components";
|
||||||
import { ReceiptIcon } from "lucide-react";
|
import { ReceiptIcon } from "lucide-react";
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
|
import { useInvoiceContext } from '../../context';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { InvoiceFormData } from "../../schemas";
|
import { InvoiceFormData } from "../../schemas";
|
||||||
|
|
||||||
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<InvoiceFormData>();
|
const { control } = useFormContext<InvoiceFormData>();
|
||||||
|
const { currency_code, language_code } = useInvoiceContext();
|
||||||
|
|
||||||
|
|
||||||
const taxes = useWatch({
|
const taxes = useWatch({
|
||||||
control,
|
control,
|
||||||
@ -16,15 +20,6 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
|||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("es-ES", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayTaxes = taxes || [];
|
const displayTaxes = taxes || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -37,21 +32,22 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
|||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{displayTaxes.map((tax, index) => (
|
{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 '>
|
<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}
|
{tax.tax_label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-2 text-sm'>
|
<div className='space-y-2 text-sm'>
|
||||||
<div className='flex justify-between'>
|
<div className='flex justify-between'>
|
||||||
<span className='text-muted-foreground'>Base para el impuesto:</span>
|
<span className='text-current'>Base para el impuesto:</span>
|
||||||
<span className='font-medium tabular-nums'>{formatCurrency(tax.taxable_amount)}</span>
|
<span className='text-base text-current tabular-nums'>{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between'>
|
<div className='flex justify-between'>
|
||||||
<span className='text-muted-foreground'>Importe de impuesto:</span>
|
<span className='text-current font-semibold'>Importe de impuesto:</span>
|
||||||
<span className='font-medium text-primary tabular-nums'>
|
<span className='text-base text-current font-semibold tabular-nums'>
|
||||||
{formatCurrency(tax.taxes_amount)}
|
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,56 +1,17 @@
|
|||||||
|
import { formatCurrency } from '@erp/core';
|
||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
||||||
import { Input, Label, Separator } from "@repo/shadcn-ui/components";
|
import { Input, Label, Separator } from "@repo/shadcn-ui/components";
|
||||||
import { CalculatorIcon } from "lucide-react";
|
import { CalculatorIcon } from "lucide-react";
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import { useInvoiceContext } from '../../context';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { InvoiceFormData } from "../../schemas";
|
import { InvoiceFormData } from "../../schemas";
|
||||||
|
|
||||||
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control, getValues } = useFormContext<InvoiceFormData>();
|
const { control, getValues } = useFormContext<InvoiceFormData>();
|
||||||
|
const { currency_code, language_code } = useInvoiceContext();
|
||||||
//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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<Fieldset {...props}>
|
||||||
@ -62,55 +23,56 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<Label className='text-sm text-muted-foreground'>Subtotal</Label>
|
<Label className='text-sm'>Subtotal</Label>
|
||||||
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'))}</span>
|
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between items-center gap-4'>
|
<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'>
|
<div className='flex items-center gap-2'>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={"discount_percentage"}
|
name={"discount_percentage"}
|
||||||
render={({
|
render={({
|
||||||
field, fieldState
|
field, fieldState
|
||||||
}) => (<Input
|
}) => (
|
||||||
readOnly={false}
|
<Input
|
||||||
value={field.value}
|
readOnly={false}
|
||||||
onChange={field.onChange}
|
value={field.value}
|
||||||
disabled={fieldState.isValidating}
|
onChange={field.onChange}
|
||||||
onBlur={field.onBlur}
|
disabled={fieldState.isValidating}
|
||||||
className='w-20 text-right'
|
onBlur={field.onBlur}
|
||||||
/>)}
|
className='w-20 text-right'
|
||||||
|
/>)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between items-center'>
|
<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'>
|
<span className='font-medium text-destructive tabular-nums'>
|
||||||
-{formatCurrency(getValues("discount_amount"))}
|
-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator className='bg-muted-foreground' />
|
||||||
|
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<Label className='text-sm text-muted-foreground'>Base imponible</Label>
|
<Label className='text-sm'>Base imponible</Label>
|
||||||
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'))}</span>
|
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<Label className='text-sm text-muted-foreground'>Total de impuestos</Label>
|
<Label className='text-sm'>Total de impuestos</Label>
|
||||||
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'))}</span>
|
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator className='bg-muted-foreground h-0.5' />
|
||||||
|
|
||||||
<div className='flex justify-between items-center'>
|
<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'>
|
<span className='text-xl font-bold text-primary tabular-nums'>
|
||||||
{formatCurrency(getValues('total_amount'))}
|
{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { formatCurrency } from '@erp/core';
|
||||||
import { useMoney } from '@erp/core/hooks';
|
import { useMoney } from '@erp/core/hooks';
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { findFocusableInCell, focusAndSelect } from './input-utils';
|
||||||
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
|
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
|
||||||
|
|
||||||
|
|
||||||
@ -15,8 +17,8 @@ export type AmountInputProps = {
|
|||||||
emptyMode?: InputEmptyMode; // cómo presentar vacío
|
emptyMode?: InputEmptyMode; // cómo presentar vacío
|
||||||
emptyText?: string; // texto en vacío para value/placeholder
|
emptyText?: string; // texto en vacío para value/placeholder
|
||||||
scale?: number; // decimales; default 2 (ej. 4 para unit_amount)
|
scale?: number; // decimales; default 2 (ej. 4 para unit_amount)
|
||||||
locale?: string; // p.ej. "es-ES"
|
languageCode?: string; // p.ej. "es-ES"
|
||||||
currency?: string; // p.ej. "EUR"
|
currencyCode?: string; // p.ej. "EUR"
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -27,31 +29,24 @@ export function AmountInput({
|
|||||||
readOnlyMode = "textlike-input",
|
readOnlyMode = "textlike-input",
|
||||||
id,
|
id,
|
||||||
"aria-label": ariaLabel = "Amount",
|
"aria-label": ariaLabel = "Amount",
|
||||||
step = 1.00,
|
|
||||||
emptyMode = "blank",
|
emptyMode = "blank",
|
||||||
emptyText = "",
|
emptyText = "",
|
||||||
scale = 2,
|
scale = 2,
|
||||||
locale,
|
languageCode = 'es',
|
||||||
currency = "EUR",
|
currencyCode = "EUR",
|
||||||
className,
|
className,
|
||||||
|
...inputProps
|
||||||
}: AmountInputProps) {
|
}: AmountInputProps) {
|
||||||
|
|
||||||
// Hook de dinero para parseo/redondeo consistente con el resto de la app
|
// 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 [raw, setRaw] = React.useState<string>("");
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|
||||||
const formatCurrencyNumber = React.useCallback(
|
const formatCurrencyNumber = React.useCallback(
|
||||||
(n: number) =>
|
(n: number) => formatCurrency(n, scale, currencyCode, languageCode),
|
||||||
new Intl.NumberFormat(locale ?? undefined, {
|
[languageCode, currencyCode, scale]
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: scale,
|
|
||||||
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
|
|
||||||
useGrouping: true,
|
|
||||||
}).format(n),
|
|
||||||
[locale, currency, scale]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derivar texto visual desde prop `value`
|
// Derivar texto visual desde prop `value`
|
||||||
@ -75,9 +70,12 @@ export function AmountInput({
|
|||||||
if (!focused) setRaw(visualText);
|
if (!focused) setRaw(visualText);
|
||||||
}, [visualText, focused]);
|
}, [visualText, focused]);
|
||||||
|
|
||||||
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = React.useCallback(
|
||||||
setRaw(e.currentTarget.value);
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}, []);
|
setRaw(e.currentTarget.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleFocus = React.useCallback(
|
const handleFocus = React.useCallback(
|
||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
@ -89,7 +87,11 @@ export function AmountInput({
|
|||||||
}
|
}
|
||||||
const current =
|
const current =
|
||||||
parse(e.currentTarget.value) ??
|
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) : "");
|
setRaw(current !== null && current !== undefined ? String(current) : "");
|
||||||
},
|
},
|
||||||
[emptyMode, emptyText, parse, value]
|
[emptyMode, emptyText, parse, value]
|
||||||
@ -118,17 +120,43 @@ export function AmountInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (readOnly) return;
|
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();
|
e.preventDefault();
|
||||||
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
|
|
||||||
const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1);
|
const current = e.currentTarget as HTMLElement;
|
||||||
const rounded = roundToScale(base + delta, scale);
|
const rowIndex = Number(current.dataset.rowIndex);
|
||||||
onChange(rounded);
|
const colIndex = Number(current.dataset.colIndex);
|
||||||
setRaw(String(rounded)); // crudo durante edición
|
|
||||||
|
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>) => {
|
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",
|
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -177,6 +206,7 @@ export function AmountInput({
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,15 +9,6 @@ import { CustomItemViewProps } from "./types";
|
|||||||
|
|
||||||
export interface BlocksViewProps extends CustomItemViewProps { }
|
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) => {
|
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<InvoiceFormData>();
|
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 { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
|
||||||
import { Control, Controller } from "react-hook-form";
|
import { Control, Controller } from "react-hook-form";
|
||||||
|
import { useInvoiceContext } from '../../../context';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
|
||||||
import { AmountInputField } from './amount-input-field';
|
import { AmountInputField } from './amount-input-field';
|
||||||
@ -39,11 +40,12 @@ export const ItemRow = ({
|
|||||||
onMoveDown,
|
onMoveDown,
|
||||||
onRemove, }: ItemRowProps) => {
|
onRemove, }: ItemRowProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { currency_code, language_code } = useInvoiceContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow data-row-index={rowIndex}>
|
<TableRow data-row-index={rowIndex}>
|
||||||
{/* selección */}
|
{/* selección */}
|
||||||
<TableCell className='align-top'>
|
<TableCell className='align-top' data-col-index={1}>
|
||||||
<div className='h-5'>
|
<div className='h-5'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`Seleccionar fila ${rowIndex + 1}`}
|
aria-label={`Seleccionar fila ${rowIndex + 1}`}
|
||||||
@ -51,19 +53,20 @@ export const ItemRow = ({
|
|||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={onToggleSelect}
|
onCheckedChange={onToggleSelect}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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'>
|
<span className='block translate-y-[-1px] text-muted-foreground tabular-nums text-xs'>
|
||||||
{rowIndex + 1}
|
{rowIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* description */}
|
{/* description */}
|
||||||
<TableCell>
|
<TableCell data-col-index={3}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.description`}
|
name={`items.${rowIndex}.description`}
|
||||||
@ -83,47 +86,58 @@ export const ItemRow = ({
|
|||||||
el.style.height = "auto";
|
el.style.height = "auto";
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}}
|
}}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* qty */}
|
{/* qty */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className='text-right' data-col-index={4}>
|
||||||
<QuantityInputField
|
<QuantityInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.quantity`}
|
name={`items.${rowIndex}.quantity`}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={`quantity-${rowIndex}`}
|
inputId={`quantity-${rowIndex}`}
|
||||||
emptyMode="blank"
|
emptyMode="blank"
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-col-index={4}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* unit */}
|
{/* unit */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className='text-right' data-col-index={5}>
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.unit_amount`}
|
name={`items.${rowIndex}.unit_amount`}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={`unit-amount-${rowIndex}`}
|
inputId={`unit-amount-${rowIndex}`}
|
||||||
scale={4}
|
scale={4}
|
||||||
locale={"es"}
|
currencyCode={currency_code}
|
||||||
|
languageCode={language_code}
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-col-index={5}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* discount */}
|
{/* discount */}
|
||||||
<TableCell className='text-right'>
|
<TableCell className='text-right' data-col-index={6}>
|
||||||
<PercentageInputField
|
<PercentageInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.discount_percentage`}
|
name={`items.${rowIndex}.discount_percentage`}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
inputId={`discount-percentage-${rowIndex}`}
|
inputId={`discount-percentage-${rowIndex}`}
|
||||||
showSuffix
|
showSuffix
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-col-index={6}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* taxes */}
|
{/* taxes */}
|
||||||
<TableCell>
|
<TableCell data-col-index={7}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.tax_codes`}
|
name={`items.${rowIndex}.tax_codes`}
|
||||||
@ -131,26 +145,30 @@ export const ItemRow = ({
|
|||||||
<CustomerInvoiceTaxesMultiSelect
|
<CustomerInvoiceTaxesMultiSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
|
data-row-index={rowIndex}
|
||||||
|
data-col-index={7}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
data-cell-focus
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* total (solo lectura) */}
|
{/* 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} >
|
<HoverCardTotalsSummary rowIndex={rowIndex} >
|
||||||
<AmountInputField
|
<AmountInputField
|
||||||
control={control}
|
control={control}
|
||||||
name={`items.${rowIndex}.total_amount`}
|
name={`items.${rowIndex}.total_amount`}
|
||||||
readOnly
|
readOnly
|
||||||
inputId={`total-amount-${rowIndex}`}
|
inputId={`total-amount-${rowIndex}`}
|
||||||
locale="es"
|
currencyCode={currency_code}
|
||||||
|
languageCode={language_code}
|
||||||
/>
|
/>
|
||||||
</HoverCardTotalsSummary>
|
</HoverCardTotalsSummary>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* actions */}
|
{/* actions */}
|
||||||
<TableCell className='pt-[4px]'>
|
<TableCell className='pt-[4px]' data-col-index={9}>
|
||||||
<div className='flex justify-end gap-0'>
|
<div className='flex justify-end gap-0'>
|
||||||
{onDuplicate && (
|
{onDuplicate && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -163,6 +181,7 @@ export const ItemRow = ({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
aria-label='Duplicar fila'
|
aria-label='Duplicar fila'
|
||||||
className='size-8 self-start -translate-y-[1px]'
|
className='size-8 self-start -translate-y-[1px]'
|
||||||
|
data-cell-focus
|
||||||
>
|
>
|
||||||
<CopyIcon className='size-4' />
|
<CopyIcon className='size-4' />
|
||||||
</Button>
|
</Button>
|
||||||
@ -180,6 +199,7 @@ export const ItemRow = ({
|
|||||||
disabled={readOnly || isFirst}
|
disabled={readOnly || isFirst}
|
||||||
aria-label='Mover arriba'
|
aria-label='Mover arriba'
|
||||||
className='size-8 self-start -translate-y-[1px]'
|
className='size-8 self-start -translate-y-[1px]'
|
||||||
|
data-cell-focus
|
||||||
>
|
>
|
||||||
<ArrowUpIcon className='size-4' />
|
<ArrowUpIcon className='size-4' />
|
||||||
</Button>
|
</Button>
|
||||||
@ -194,6 +214,7 @@ export const ItemRow = ({
|
|||||||
disabled={readOnly || isLast}
|
disabled={readOnly || isLast}
|
||||||
aria-label='Mover abajo'
|
aria-label='Mover abajo'
|
||||||
className='size-8 self-start -translate-y-[1px]'
|
className='size-8 self-start -translate-y-[1px]'
|
||||||
|
data-cell-focus
|
||||||
>
|
>
|
||||||
<ArrowDownIcon className='size-4' />
|
<ArrowDownIcon className='size-4' />
|
||||||
</Button>
|
</Button>
|
||||||
@ -208,6 +229,7 @@ export const ItemRow = ({
|
|||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
aria-label='Eliminar fila'
|
aria-label='Eliminar fila'
|
||||||
className='size-8 self-start -translate-y-[1px]'
|
className='size-8 self-start -translate-y-[1px]'
|
||||||
|
data-cell-focus
|
||||||
>
|
>
|
||||||
<Trash2Icon className='size-4' />
|
<Trash2Icon className='size-4' />
|
||||||
</Button>
|
</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 { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useItemsTableNavigation } from '../../../hooks';
|
import { useInvoiceContext } from '../../../context';
|
||||||
|
import { useInvoiceAutoRecalc, useItemsTableNavigation } from '../../../hooks';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
import { InvoiceFormData, InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
|
||||||
import { ItemRow } from './item-row';
|
import { ItemRow } from './item-row';
|
||||||
import { ItemsEditorToolbar } from './items-editor-toolbar';
|
import { ItemsEditorToolbar } from './items-editor-toolbar';
|
||||||
|
|
||||||
@ -17,7 +18,9 @@ const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
|
|||||||
|
|
||||||
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
|
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = useFormContext();
|
const context = useInvoiceContext();
|
||||||
|
const form = useFormContext<InvoiceFormData>();
|
||||||
|
const { control } = form;
|
||||||
|
|
||||||
// Navegación y operaciones sobre las filas
|
// Navegación y operaciones sobre las filas
|
||||||
const tableNav = useItemsTableNavigation(form, {
|
const tableNav = useItemsTableNavigation(form, {
|
||||||
@ -26,7 +29,6 @@ export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
|
|||||||
firstEditableField: "description",
|
firstEditableField: "description",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { control } = form;
|
|
||||||
const { fieldArray: { fields } } = tableNav;
|
const { fieldArray: { fields } } = tableNav;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -38,6 +40,8 @@ export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
} = useRowSelection(fields.length);
|
} = useRowSelection(fields.length);
|
||||||
|
|
||||||
|
useInvoiceAutoRecalc(form, context);
|
||||||
|
|
||||||
const handleAddSelection = useCallback(() => {
|
const handleAddSelection = useCallback(() => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
tableNav.addEmpty(true);
|
tableNav.addEmpty(true);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { findFocusableInCell, focusAndSelect } from './input-utils';
|
||||||
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
|
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
|
||||||
|
|
||||||
export type PercentageInputProps = {
|
export type PercentageInputProps = {
|
||||||
@ -36,6 +37,7 @@ export function PercentageInput({
|
|||||||
showSuffix = true,
|
showSuffix = true,
|
||||||
locale,
|
locale,
|
||||||
className,
|
className,
|
||||||
|
...inputProps
|
||||||
}: PercentageInputProps) {
|
}: PercentageInputProps) {
|
||||||
const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim();
|
const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim();
|
||||||
|
|
||||||
@ -148,18 +150,43 @@ export function PercentageInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (readOnly) return;
|
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();
|
e.preventDefault();
|
||||||
const base = parseLocaleNumber(isShowingEmptyValue ? "" : raw) ?? 0;
|
|
||||||
const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1);
|
const current = e.currentTarget as HTMLElement;
|
||||||
const next = clamp(base + delta);
|
const rowIndex = Number(current.dataset.rowIndex);
|
||||||
const rounded = roundToScale(next, scale);
|
const colIndex = Number(current.dataset.colIndex);
|
||||||
onChange(rounded);
|
|
||||||
setRaw(String(rounded)); // crudo durante edición
|
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
|
// 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",
|
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -213,6 +241,7 @@ export function PercentageInput({
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { useQuantity } from '@erp/core/hooks';
|
import { useQuantity } from '@erp/core/hooks';
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { findFocusableInCell, focusAndSelect } from './input-utils';
|
||||||
|
|
||||||
|
|
||||||
export type InputEmptyMode = "blank" | "placeholder" | "value";
|
export type InputEmptyMode = "blank" | "placeholder" | "value";
|
||||||
@ -17,7 +18,6 @@ export type QuantityInputProps = {
|
|||||||
readOnlyMode?: InputReadOnlyMode;
|
readOnlyMode?: InputReadOnlyMode;
|
||||||
id?: string;
|
id?: string;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
step?: number; // default 1
|
|
||||||
emptyMode?: InputEmptyMode; // cómo presentar vacío
|
emptyMode?: InputEmptyMode; // cómo presentar vacío
|
||||||
emptyText?: string; // texto de vacío para value-mode/placeholder
|
emptyText?: string; // texto de vacío para value-mode/placeholder
|
||||||
scale?: number; // default 2
|
scale?: number; // default 2
|
||||||
@ -36,7 +36,6 @@ export function QuantityInput({
|
|||||||
readOnlyMode = "textlike-input",
|
readOnlyMode = "textlike-input",
|
||||||
id,
|
id,
|
||||||
"aria-label": ariaLabel = "Quantity",
|
"aria-label": ariaLabel = "Quantity",
|
||||||
step = 1,
|
|
||||||
emptyMode = "blank",
|
emptyMode = "blank",
|
||||||
emptyText = "",
|
emptyText = "",
|
||||||
scale = 2,
|
scale = 2,
|
||||||
@ -44,6 +43,7 @@ export function QuantityInput({
|
|||||||
className,
|
className,
|
||||||
displaySuffix,
|
displaySuffix,
|
||||||
nbspBeforeSuffix = true,
|
nbspBeforeSuffix = true,
|
||||||
|
...inputProps
|
||||||
}: QuantityInputProps) {
|
}: QuantityInputProps) {
|
||||||
const { parse, roundToScale } = useQuantity({ defaultScale: scale });
|
const { parse, roundToScale } = useQuantity({ defaultScale: scale });
|
||||||
const [raw, setRaw] = React.useState<string>("");
|
const [raw, setRaw] = React.useState<string>("");
|
||||||
@ -76,11 +76,14 @@ export function QuantityInput({
|
|||||||
typeof value === "number"
|
typeof value === "number"
|
||||||
? value
|
? value
|
||||||
: (parse(String(value)) ?? Number(String(value).replaceAll(",", ""))); // tolera string numérico
|
: (parse(String(value)) ?? Number(String(value).replaceAll(",", ""))); // tolera string numérico
|
||||||
|
|
||||||
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
|
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
|
||||||
const n = roundToScale(numeric, scale);
|
const n = roundToScale(numeric, scale);
|
||||||
const numTxt = formatNumber(n);
|
const numTxt = formatNumber(n);
|
||||||
const suf = suffixFor(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]);
|
}, [value, emptyMode, emptyText, parse, roundToScale, scale, formatNumber, suffixFor, nbspBeforeSuffix]);
|
||||||
|
|
||||||
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
|
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
|
||||||
@ -99,6 +102,7 @@ export function QuantityInput({
|
|||||||
|
|
||||||
const handleFocus = React.useCallback(
|
const handleFocus = React.useCallback(
|
||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
|
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
|
||||||
setRaw("");
|
setRaw("");
|
||||||
@ -120,6 +124,7 @@ export function QuantityInput({
|
|||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
const txt = e.currentTarget.value.trim();
|
const txt = e.currentTarget.value.trim();
|
||||||
|
|
||||||
if (txt === "" || isShowingEmptyValue) {
|
if (txt === "" || isShowingEmptyValue) {
|
||||||
onChange("");
|
onChange("");
|
||||||
setRaw(emptyMode === "value" ? emptyText : "");
|
setRaw(emptyMode === "value" ? emptyText : "");
|
||||||
@ -141,17 +146,43 @@ export function QuantityInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
if (readOnly) return;
|
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();
|
e.preventDefault();
|
||||||
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
|
|
||||||
const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1);
|
const current = e.currentTarget as HTMLElement;
|
||||||
const rounded = roundToScale(base + delta, scale);
|
const rowIndex = Number(current.dataset.rowIndex);
|
||||||
onChange(rounded);
|
const colIndex = Number(current.dataset.colIndex);
|
||||||
setRaw(String(rounded)); // crudo durante edición
|
|
||||||
|
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 ───────────────────────────────
|
// ── 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",
|
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -205,6 +237,7 @@ export function QuantityInput({
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
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 {
|
export interface InvoiceHeaderCalcInput {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
|
header_discount_amount: number;
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceHeaderCalcResult {
|
export interface InvoiceHeaderCalcResult {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
|
header_discount_amount: number;
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
}
|
}
|
||||||
@ -24,26 +31,24 @@ export function calculateInvoiceHeaderAmounts(
|
|||||||
items: InvoiceHeaderCalcInput[],
|
items: InvoiceHeaderCalcInput[],
|
||||||
currency: string
|
currency: string
|
||||||
): InvoiceHeaderCalcResult {
|
): InvoiceHeaderCalcResult {
|
||||||
const scale = 2;
|
const defaultScale = 2;
|
||||||
const toDinero = (n: number) =>
|
|
||||||
Dinero({
|
|
||||||
amount: n === 0 ? 0 : Math.round(n * 10 ** scale),
|
|
||||||
precision: scale,
|
|
||||||
currency: currency as Currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
let subtotal = toDinero(0);
|
let subtotal = toDinero(0, defaultScale, currency);
|
||||||
let discount = toDinero(0);
|
let discount = toDinero(0, defaultScale, currency);
|
||||||
let taxable = toDinero(0);
|
let header_discount = toDinero(0, defaultScale, currency);
|
||||||
let taxes = toDinero(0);
|
let taxable = toDinero(0, defaultScale, currency);
|
||||||
let total = toDinero(0);
|
let taxes = toDinero(0, defaultScale, currency);
|
||||||
|
let total = toDinero(0, defaultScale, currency);
|
||||||
|
const taxes_summary: InvoiceItemTaxSummary[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
subtotal = subtotal.add(toDinero(item.subtotal_amount));
|
subtotal = subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
|
||||||
discount = discount.add(toDinero(item.discount_amount));
|
discount = discount.add(toDinero(item.discount_amount, defaultScale, currency));
|
||||||
taxable = taxable.add(toDinero(item.taxable_amount));
|
header_discount = header_discount.add(toDinero(item.discount_amount, defaultScale, currency));
|
||||||
taxes = taxes.add(toDinero(item.taxes_amount));
|
taxable = taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
|
||||||
total = total.add(toDinero(item.total_amount));
|
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();
|
const toNum = (d: Dinero.Dinero) => d.toUnit();
|
||||||
@ -51,8 +56,53 @@ export function calculateInvoiceHeaderAmounts(
|
|||||||
return {
|
return {
|
||||||
subtotal_amount: toNum(subtotal),
|
subtotal_amount: toNum(subtotal),
|
||||||
discount_amount: toNum(discount),
|
discount_amount: toNum(discount),
|
||||||
|
header_discount_amount: toNum(header_discount),
|
||||||
taxable_amount: toNum(taxable),
|
taxable_amount: toNum(taxable),
|
||||||
taxes_amount: toNum(taxes),
|
taxes_amount: toNum(taxes),
|
||||||
total_amount: toNum(total),
|
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 { TaxCatalogProvider, TaxItemType } from "@erp/core";
|
||||||
import Dinero, { Currency } from "dinero.js";
|
import { toDinero, toNum } from "./calculate-utils";
|
||||||
|
|
||||||
export interface InvoiceItemCalcInput {
|
export interface InvoiceItemCalcInput {
|
||||||
quantity?: string; // p.ej. "3.5"
|
quantity?: string; // p.ej. "3.5"
|
||||||
unit_amount?: string; // p.ej. "125.75"
|
unit_amount?: string; // p.ej. "125.75"
|
||||||
discount_percentage?: string; // p.ej. "10" (=> 10%)
|
discount_percentage?: string; // p.ej. "10" (=> 10%)
|
||||||
|
header_discount_percentage?: string; // p.ej. "5" (=> 5%)
|
||||||
tax_codes: string[]; // ["iva_21", ...]
|
tax_codes: string[]; // ["iva_21", ...]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InvoiceItemTaxSummary = TaxItemType & {
|
||||||
|
taxable_amount: number;
|
||||||
|
taxes_amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface InvoiceItemCalcResult {
|
export interface InvoiceItemCalcResult {
|
||||||
subtotal_amount: number;
|
subtotal_amount: number;
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
|
header_discount_amount: number;
|
||||||
taxable_amount: number;
|
taxable_amount: number;
|
||||||
taxes_amount: number;
|
taxes_amount: number;
|
||||||
|
taxes_summary: InvoiceItemTaxSummary[];
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,48 +33,65 @@ export function calculateInvoiceItemAmounts(
|
|||||||
currency: string,
|
currency: string,
|
||||||
taxCatalog: TaxCatalogProvider
|
taxCatalog: TaxCatalogProvider
|
||||||
): InvoiceItemCalcResult {
|
): InvoiceItemCalcResult {
|
||||||
const scale = 4;
|
const defaultScale = 4;
|
||||||
const toDinero = (n: number) =>
|
const taxesSummary: InvoiceItemTaxSummary[] = [];
|
||||||
Dinero({
|
|
||||||
amount: n === 0 ? 0 : Math.round(n * 10 ** scale),
|
|
||||||
precision: scale,
|
|
||||||
currency: currency as Currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
const qty = Number.parseFloat(item.quantity || "0") || 0;
|
const qty = Number.parseFloat(item.quantity || "0") || 0;
|
||||||
const unit = Number.parseFloat(item.unit_amount || "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
|
// Subtotal = cantidad × precio unitario
|
||||||
const subtotal = toDinero(qty * unit);
|
const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty);
|
||||||
|
|
||||||
// Descuento = subtotal × (pct / 100)
|
// Descuento = subtotal × (item_pct / 100)
|
||||||
const discount = subtotal.percentage(pct);
|
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
|
// Base imponible
|
||||||
const taxable = subtotal.subtract(discount);
|
const taxable_amount = subtotal_w_discount_amount.subtract(header_discount);
|
||||||
|
|
||||||
// Impuestos acumulados
|
// Impuestos acumulados con signo
|
||||||
let taxes = toDinero(0);
|
let taxes_amount = toDinero(0, defaultScale, currency);
|
||||||
for (const code of item.tax_codes ?? []) {
|
for (const code of item.tax_codes ?? []) {
|
||||||
const tax = taxCatalog.findByCode(code);
|
const tax = taxCatalog.findByCode(code);
|
||||||
|
if (tax.isNone()) continue;
|
||||||
|
|
||||||
tax.map((taxItem) => {
|
tax.map((taxItem) => {
|
||||||
const pctValue = Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
|
const tax_pct_value =
|
||||||
const taxAmount = taxable.percentage(pctValue);
|
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
|
||||||
taxes = taxes.add(taxAmount);
|
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);
|
const total = taxable_amount.add(taxes_amount);
|
||||||
|
|
||||||
// Devuelve valores desescalados (número con 2 decimales exactos)
|
|
||||||
const toNum = (d: Dinero.Dinero) => d.toUnit();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subtotal_amount: toNum(subtotal),
|
subtotal_amount: toNum(subtotal_amount),
|
||||||
discount_amount: toNum(discount),
|
discount_amount: toNum(discount_amount),
|
||||||
taxable_amount: toNum(taxable),
|
header_discount_amount: toNum(header_discount),
|
||||||
taxes_amount: toNum(taxes),
|
taxable_amount: toNum(taxable_amount),
|
||||||
|
taxes_amount: toNum(taxes_amount),
|
||||||
|
taxes_summary: taxesSummary,
|
||||||
total_amount: toNum(total),
|
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 { TaxCatalogProvider } from "@erp/core";
|
||||||
import * as React from "react";
|
import React from "react";
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
InvoiceItemCalcResult,
|
InvoiceItemCalcResult,
|
||||||
@ -24,8 +24,6 @@ export function useInvoiceAutoRecalc(
|
|||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
trigger,
|
trigger,
|
||||||
formState: { isDirty, isLoading, isSubmitting },
|
formState: { isDirty, isLoading, isSubmitting },
|
||||||
} = form;
|
} = form;
|
||||||
@ -37,18 +35,9 @@ export function useInvoiceAutoRecalc(
|
|||||||
new Map()
|
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)
|
// Cálculo de una línea (usa dominio puro)
|
||||||
const calculateItemTotals = React.useCallback(
|
const calculateItemTotals = React.useCallback(
|
||||||
(item: InvoiceItemFormData) => {
|
(item: InvoiceItemFormData, header_discount_percentage: number) => {
|
||||||
const sanitizeString = (v?: number | string) =>
|
const sanitizeString = (v?: number | string) =>
|
||||||
v && !Number.isNaN(Number(v)) ? String(v) : "0";
|
v && !Number.isNaN(Number(v)) ? String(v) : "0";
|
||||||
|
|
||||||
@ -57,6 +46,7 @@ export function useInvoiceAutoRecalc(
|
|||||||
quantity: sanitizeString(item.quantity),
|
quantity: sanitizeString(item.quantity),
|
||||||
unit_amount: sanitizeString(item.unit_amount),
|
unit_amount: sanitizeString(item.unit_amount),
|
||||||
discount_percentage: sanitizeString(item.discount_percentage),
|
discount_percentage: sanitizeString(item.discount_percentage),
|
||||||
|
header_discount_percentage: sanitizeString(header_discount_percentage),
|
||||||
tax_codes: item.tax_codes,
|
tax_codes: item.tax_codes,
|
||||||
},
|
},
|
||||||
currency_code,
|
currency_code,
|
||||||
@ -66,40 +56,21 @@ export function useInvoiceAutoRecalc(
|
|||||||
[taxCatalog, currency_code]
|
[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)
|
// Totales globales (usa funciones del dominio)
|
||||||
const calculateInvoiceTotals = React.useCallback(
|
const calculateInvoiceTotals = React.useCallback(
|
||||||
(items: InvoiceItemFormData[]) => {
|
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
|
||||||
const lines = items
|
const lines = items
|
||||||
.filter((i) => !i.is_non_valued)
|
.filter((i) => !i.is_non_valued)
|
||||||
.map((i) => {
|
.map((i) => {
|
||||||
const totals = calculateItemTotals(i);
|
const itemTotals = calculateItemTotals(i, header_discount_percentage);
|
||||||
return {
|
return {
|
||||||
subtotal_amount: totals.subtotal_amount,
|
subtotal_amount: itemTotals.subtotal_amount,
|
||||||
discount_amount: totals.discount_amount,
|
discount_amount: itemTotals.discount_amount,
|
||||||
taxable_amount: totals.taxable_amount,
|
header_discount_amount: itemTotals.header_discount_amount,
|
||||||
taxes_amount: totals.taxes_amount,
|
taxable_amount: itemTotals.taxable_amount,
|
||||||
total_amount: totals.total_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]
|
[calculateItemTotals, currency_code]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Suscripción reactiva a cambios del formulario
|
// Observamos el formulario esperando cualquier cambio
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log("recalculo algo?");
|
console.log("recalculo algo?");
|
||||||
|
|
||||||
@ -116,19 +87,27 @@ export function useInvoiceAutoRecalc(
|
|||||||
|
|
||||||
const subscription = watch((formData, { name, type }) => {
|
const subscription = watch((formData, { name, type }) => {
|
||||||
console.log(name, type);
|
console.log(name, type);
|
||||||
|
|
||||||
const items = (formData?.items || []) as InvoiceItemFormData[];
|
const items = (formData?.items || []) as InvoiceItemFormData[];
|
||||||
|
const header_discount_percentage = formData?.discount_percentage || 0;
|
||||||
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
|
|
||||||
// Detectar cambios en la cabecera
|
// Detectar cambios en la cabecera
|
||||||
if (name === "discount_percentage") {
|
if (name === "discount_percentage") {
|
||||||
// Recalcular totales de factura
|
// Recalcular totales de factura
|
||||||
const invoiceTotals = calculateInvoiceTotals(items);
|
const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage);
|
||||||
|
|
||||||
// Cabecera
|
// Estableer valores en cabecera
|
||||||
setInvoiceTotals(form, invoiceTotals);
|
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([
|
trigger([
|
||||||
"subtotal_amount",
|
"subtotal_amount",
|
||||||
"discount_amount",
|
"discount_amount",
|
||||||
@ -149,13 +128,17 @@ export function useInvoiceAutoRecalc(
|
|||||||
console.log("2.1. recalculo items!");
|
console.log("2.1. recalculo items!");
|
||||||
const item = items[index] as InvoiceItemFormData;
|
const item = items[index] as InvoiceItemFormData;
|
||||||
const prevTotals = itemCache.current.get(index);
|
const prevTotals = itemCache.current.get(index);
|
||||||
const newTotals = calculateItemTotals(item);
|
const newTotals = calculateItemTotals(item, header_discount_percentage);
|
||||||
|
|
||||||
console.log(prevTotals, newTotals);
|
|
||||||
|
|
||||||
// Si no hay cambios en los totales, no tocamos nada
|
// Si no hay cambios en los totales, no tocamos nada
|
||||||
const itemHasChanges =
|
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) {
|
if (!itemHasChanges) {
|
||||||
console.log("No hay cambios, me voy!!!");
|
console.log("No hay cambios, me voy!!!");
|
||||||
@ -169,19 +152,13 @@ export function useInvoiceAutoRecalc(
|
|||||||
setInvoiceItemTotals(form, index, newTotals);
|
setInvoiceItemTotals(form, index, newTotals);
|
||||||
|
|
||||||
// Recalcular totales de factura
|
// Recalcular totales de factura
|
||||||
//const itemTotals = calculateItemTotals(item);
|
const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage);
|
||||||
const invoiceTotals = calculateInvoiceTotals(items);
|
|
||||||
// Actualizar totales globales incrementalmente
|
|
||||||
//const prevTotals = invoiceTotalsRef.current;
|
|
||||||
//const newTotals = recalcTotalsIncrementally(prevTotals, prevLine, newLine);
|
|
||||||
//invoiceTotalsRef.current = newTotals;
|
|
||||||
|
|
||||||
console.log(invoiceTotals);
|
console.log(invoiceTotals);
|
||||||
|
|
||||||
// Cabecera
|
// Estableer valores en cabecera
|
||||||
setInvoiceTotals(form, invoiceTotals);
|
setInvoiceTotals(form, invoiceTotals);
|
||||||
|
|
||||||
// 3) valida una vez (opcional)
|
|
||||||
trigger([
|
trigger([
|
||||||
"items",
|
"items",
|
||||||
"subtotal_amount",
|
"subtotal_amount",
|
||||||
@ -196,16 +173,12 @@ export function useInvoiceAutoRecalc(
|
|||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [
|
}, [
|
||||||
|
form,
|
||||||
watch,
|
watch,
|
||||||
trigger,
|
trigger,
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
isDirty,
|
isDirty,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
itemCache,
|
|
||||||
setInvoiceItemTotals,
|
|
||||||
setInvoiceTotals,
|
|
||||||
calculateItemTotals,
|
calculateItemTotals,
|
||||||
calculateInvoiceTotals,
|
calculateInvoiceTotals,
|
||||||
]);
|
]);
|
||||||
@ -252,7 +225,7 @@ function setInvoiceTotals(
|
|||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: false,
|
shouldValidate: false,
|
||||||
});
|
});
|
||||||
setValue("discount_amount", invoiceTotals.discount_amount, {
|
setValue("discount_amount", invoiceTotals.header_discount_amount, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: false,
|
shouldValidate: false,
|
||||||
});
|
});
|
||||||
@ -268,4 +241,14 @@ function setInvoiceTotals(
|
|||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: false,
|
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 * as React from "react";
|
||||||
import { FieldValues, UseFormReturn, useFieldArray } from "react-hook-form";
|
import { FieldValues, Path, UseFormReturn, useFieldArray } from "react-hook-form";
|
||||||
|
|
||||||
export interface UseItemsTableNavigationOptions {
|
interface UseItemsTableNavigationOptions<TFieldValues extends FieldValues> {
|
||||||
name: string;
|
/** Nombre del array de líneas en el formulario (tipo-safe) */
|
||||||
createEmpty: () => Record<string, unknown>;
|
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;
|
firstEditableField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useItemsTableNavigation(
|
export function useItemsTableNavigation<TFieldValues extends FieldValues = FieldValues>(
|
||||||
form: UseFormReturn<FieldValues>,
|
form: UseFormReturn<TFieldValues>,
|
||||||
{ name, createEmpty, firstEditableField = "description" }: UseItemsTableNavigationOptions
|
{
|
||||||
|
name,
|
||||||
|
createEmpty,
|
||||||
|
firstEditableField = "description",
|
||||||
|
}: UseItemsTableNavigationOptions<TFieldValues>
|
||||||
) {
|
) {
|
||||||
const { control, getValues, setFocus } = form;
|
const { control, getValues, setFocus } = form;
|
||||||
const fa = useFieldArray({ control, name });
|
const fa = useFieldArray<TFieldValues>({ control, name });
|
||||||
|
|
||||||
// Desestructurar para evitar recreaciones
|
// Desestructurar para evitar recreaciones
|
||||||
const { append, insert, remove: faRemove, move } = fa;
|
const { append, insert, remove: faRemove, move } = fa;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
PageHeader
|
PageHeader
|
||||||
} from "../../components";
|
} from "../../components";
|
||||||
import { useInvoiceContext } from '../../context';
|
import { useInvoiceContext } from '../../context';
|
||||||
import { useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
|
import { useUpdateCustomerInvoice } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
CustomerInvoice,
|
CustomerInvoice,
|
||||||
@ -58,7 +58,6 @@ export const InvoiceUpdateComp = ({
|
|||||||
disabled: !invoiceData || isUpdating
|
disabled: !invoiceData || isUpdating
|
||||||
});
|
});
|
||||||
|
|
||||||
useInvoiceAutoRecalc(form, context);
|
|
||||||
|
|
||||||
const handleSubmit = (formData: InvoiceFormData) => {
|
const handleSubmit = (formData: InvoiceFormData) => {
|
||||||
mutate(
|
mutate(
|
||||||
|
|||||||
@ -556,6 +556,9 @@ importers:
|
|||||||
'@hookform/devtools':
|
'@hookform/devtools':
|
||||||
specifier: ^4.4.0
|
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)
|
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':
|
'@types/express':
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.23
|
version: 4.17.23
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user