Facturas de cliente

This commit is contained in:
David Arranz 2025-10-12 20:36:33 +02:00
parent 9fb2955d30
commit ef8a20d296
40 changed files with 3699 additions and 4855 deletions

View File

@ -33,8 +33,6 @@
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/response-time": "^2.3.8",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tsconfig-paths": "^4.2.0",
"tsup": "8.4.0",
"tsx": "4.19.4",

View File

@ -32,10 +32,10 @@ export const App = () => {
const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken,
getAccessToken: () => null, //getAccessToken,
onAuthError: () => {
console.error("APP, Error de autenticación");
clearAccessToken();
//console.error("APP, Error de autenticación");
//clearAccessToken();
//window.location.href = "/login"; // o usar navegación programática
},
});

View File

@ -8,15 +8,13 @@ type UseHookFormProps<TFields extends FieldValues = FieldValues, TContext = any>
TContext
> & {
resolverSchema: z4.$ZodType<TFields, any>;
defaultValues: UseFormProps<TFields>["defaultValues"];
values: UseFormProps<TFields>["values"];
initialValues: UseFormProps<TFields>["defaultValues"];
onDirtyChange?: (isDirty: boolean) => void;
};
export function useHookForm<TFields extends FieldValues = FieldValues, TContext = any>({
resolverSchema,
defaultValues,
values,
initialValues,
disabled,
onDirtyChange,
...rest
@ -24,8 +22,7 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
const form = useForm<TFields, TContext>({
...rest,
resolver: zodResolver(resolverSchema),
defaultValues,
values,
defaultValues: initialValues,
disabled,
});
@ -39,12 +36,12 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
useEffect(() => {
const applyReset = async () => {
const values = typeof defaultValues === "function" ? await defaultValues() : defaultValues;
const values = typeof initialValues === "function" ? await initialValues() : initialValues;
form.reset(values);
};
applyReset();
}, [defaultValues, form]);
}, [initialValues, form]);
return form;
}

View File

@ -29,6 +29,7 @@
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/react-i18next": "^8.1.0",
"@types/react-router-dom": "^5.3.3",
"typescript": "^5.8.3"
},
"dependencies": {

View File

@ -1,5 +1,5 @@
import { PropsWithChildren } from "react";
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
return <section>{children}</section>;
return <div>{children}</div>;
};

View File

@ -1,13 +1,13 @@
import { FieldErrors, useFormContext } from "react-hook-form";
import { FormDebug } from "@erp/core/components";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@repo/shadcn-ui/components';
import { useTranslation } from "../../i18n";
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 { InvoiceTaxSummary } from "./invoice-tax-summary";
import { InvoiceTotals } from "./invoice-totals";
import { InvoiceTaxSummary } from './invoice-tax-summary';
import { InvoiceTotals } from './invoice-totals';
import { InvoiceRecipient } from "./recipient";
interface CustomerInvoiceFormProps {
@ -23,46 +23,43 @@ export const CustomerInvoiceEditForm = ({
onError,
className,
}: CustomerInvoiceFormProps) => {
const { t } = useTranslation();
const form = useFormContext<InvoiceFormData>();
console.log("CustomerInvoiceEditForm")
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={className}>
<section className={cn("space-y-6", className)}>
<div className='w-full'>
<FormDebug />
</div>
<div className="w-full gap-6 grid grid-cols-1 mx-auto">
<ResizablePanelGroup direction="horizontal" className="mx-auto grid w-full grid-cols-1 gap-6 lg:grid-cols-3 items-stretch">
<ResizablePanel className="lg:col-start-1 lg:col-span-2 h-full" defaultSize={65}>
<InvoiceBasicInfoFields className="h-full flex flex-col" />
<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">
<InvoiceBasicInfoFields className="h-full flex flex-col" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel className="lg:col-end-4 h-full" defaultSize={35}>
<InvoiceRecipient className="h-full flex flex-col" />
<div className="h-full ">
<InvoiceRecipient className="h-full flex flex-col" />
</div>
</ResizablePanel>
</ResizablePanelGroup>
<div className="h-full ">
<InvoiceNotes className="h-full flex flex-col" />
</div>
<div className="mx-auto grid w-full grid-cols-1 gap-6 lg:grid-cols-3 items-stretch">
<div className="lg:col-start-1 lg:col-span-full h-full">
{/* <InvoiceItems className="h-full flex flex-col"/> */}
</div>
<div className="lg:col-start-1 h-full">
<InvoiceNotes className="h-full flex flex-col" />
</div>
<div className="h-full">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full">
<InvoiceTotals 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>
<div className="h-full lg:col-start-1">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceTotals className="h-full flex flex-col" />
</div>
</section>
</form>
);

View File

@ -25,8 +25,8 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field >
<FieldGroup className='grid grid-cols-1 gap-x-6 xl:grid-cols-2'>
<Field>
<TextField
control={control}
name='invoice_number'
@ -70,7 +70,7 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
/>
</Field>
<Field className='lg:col-start-1 lg:col-span-1'>
<Field>
<TextField
typePreset='text'
maxLength={256}
@ -82,7 +82,7 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
/>
</Field>
<Field className='lg:col-span-3'>
<Field>
<TextField
typePreset='text'
maxLength={256}

View File

@ -8,7 +8,7 @@ import { InvoiceFormData } from "../../schemas";
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control, getValues } = useFormContext<InvoiceFormData>();
const { control } = useFormContext<InvoiceFormData>();
const taxes = useWatch({
control,
@ -16,8 +16,6 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
defaultValue: [],
});
console.log(getValues());
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
@ -62,7 +60,7 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
{displayTaxes.length === 0 && (
<div className='text-center py-6 text-muted-foreground'>
<ReceiptIcon className='h-8 w-8 mx-auto mb-2 opacity-50' />
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
<p className='text-sm'>No hay impuestos aplicados</p>
</div>
)}

View File

@ -1,160 +0,0 @@
import { MoneyDTO } from '@erp/core';
import { useMoney } from '@erp/core/hooks';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
type AmountDTOEmptyMode = "blank" | "placeholder" | "value";
type AmountDTOReadOnlyMode = "textlike-input" | "normal";
type AmountDTOInputProps = {
value: MoneyDTO | null | undefined;
onChange: (next: MoneyDTO | null) => void;
readOnly?: boolean;
readOnlyMode?: AmountDTOReadOnlyMode; // "textlike-input" evita foco/edición
id?: string;
"aria-label"?: string;
className?: string;
step?: number; // incremento por flechas (p.ej. 0.01)
emptyMode?: AmountDTOEmptyMode; // representación cuando DTO está vacío
emptyText?: string;
scale?: 0 | 1 | 2 | 3 | 4; // decimales del DTO; por defecto 4
currencyCode?: string; // si no viene en value (fallback)
locale?: string; // opcional: fuerza locale (sino usa i18n del hook)
currencyFallback?: string; // p.ej. "EUR" si faltase en el DTO
};
export function AmountDTOInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Amount",
className,
step = 0.01,
emptyMode = "blank",
emptyText = "",
scale = 4,
locale = "es-ES",
currencyFallback = "EUR",
}: AmountDTOInputProps) {
const {
formatCurrency,
parse,
toNumber,
fromNumber,
roundToScale,
isEmptyMoneyDTO,
defaultScale,
} = useMoney({ locale });
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const ref = React.useRef<HTMLInputElement>(null);
const sc = Number(value?.scale ?? (scale ?? defaultScale));
const cur = value?.currency_code ?? currencyFallback;
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Visual → con símbolo si hay DTO; vacío según emptyMode
React.useEffect(() => {
if (focused) return;
if (isEmptyMoneyDTO(value)) {
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
setRaw(formatCurrency(value!)); // p.ej. "1.234,5600 €" según locale/scale
}, [value, focused, emptyMode, emptyText, formatCurrency, isEmptyMoneyDTO]);
// ── readOnly como INPUT sin foco/edición (text-like) ─────────────────────
if (readOnly && readOnlyMode === "textlike-input") {
const display = isEmptyMoneyDTO(value)
? (emptyMode === "value" ? emptyText : "")
: formatCurrency(value!);
return (
<input
id={id}
aria-label={ariaLabel}
readOnly={readOnly}
// evita que entre en foco/edición
tabIndex={-1}
onFocus={(e) => e.currentTarget.blur()}
onMouseDown={(e) => e.preventDefault()} // también evita caret al click
onKeyDown={(e) => e.preventDefault()}
value={display}
className={cn(
// apariencia de texto, sin borde ni ring ni caret
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0",
"[caret-color:transparent] cursor-default",
className
)}
// permitir copiar con mouse (sin foco); si no quieres selección, añade "select-none"
/>
);
}
// MODO EDITABLE o readOnly normal (permite poner el foco)
return (
<input
ref={ref}
id={id}
aria-label={ariaLabel}
readOnly={readOnly /* si quieres readOnly estándar y foco, usa readOnlyMode="normal" */}
inputMode='decimal'
pattern={focused ? '[0-9]*[.,]?[0-9]*' : undefined}
className={cn(
"w-full bg-transparent p-0 text-right focus:outline-none tabular-nums focus:bg-background",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className
)}
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parse(e.currentTarget.value) ??
(isEmptyMoneyDTO(value) ? null : toNumber(value!));
setRaw(n !== null ? String(n) : "");
}}
onBlur={(e) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, sc);
const dto: MoneyDTO = fromNumber(rounded, cur as any, sc);
onChange(dto);
setRaw(formatCurrency(dto));
}}
onKeyDown={(e) => {
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
const delta = (e.shiftKey ? 10 : 1) * step;
const next = e.key === "ArrowUp" ? base + delta : base - delta;
const rounded = roundToScale(next, sc);
onChange(fromNumber(rounded, cur as any, sc));
setRaw(String(rounded));
}
}}
/>
);
}

View File

@ -1,44 +1,30 @@
import { MoneyDTO } from '@erp/core';
import {
FormControl, FormDescription,
FormField, FormItem, FormLabel, FormMessage,
} from "@repo/shadcn-ui/components";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { AmountDTOInput } from './amount-dto-input';
import { AmountInput, AmountInputProps } from './amount-input';
type AmountDTOInputFieldProps<T extends FieldValues> = {
type AmountInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
readOnly?: boolean;
inputId?: string;
"aria-label"?: string;
className?: string;
step?: number;
emptyMode?: "blank" | "placeholder" | "value";
emptyText?: string;
scale?: 0 | 1 | 2 | 3 | 4;
locale?: string; // p.ej. invoice.language_code ("es", "es-ES", etc.)
};
} & Omit<AmountInputProps, "value" | "onChange">
export function AmountDTOInputField<T extends FieldValues>({
export function AmountInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
readOnly = false,
inputId,
"aria-label": ariaLabel = "amount",
className,
step = 0.01,
emptyMode = "blank",
emptyText = "",
scale = 4,
locale,
}: AmountDTOInputFieldProps<T>) {
...inputProps
}: AmountInputFieldProps<T>) {
return (
<FormField
control={control}
@ -51,18 +37,11 @@ export function AmountDTOInputField<T extends FieldValues>({
</FormLabel>
) : null}
<FormControl>
<AmountDTOInput
<AmountInput
id={inputId}
aria-label={ariaLabel}
value={field.value as MoneyDTO | null}
value={field.value}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
scale={scale}
locale={locale}
className={className}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}

View File

@ -0,0 +1,182 @@
import { useMoney } from '@erp/core/hooks';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
export type AmountInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.01
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"
className?: string;
};
export function AmountInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Amount",
step = 1.00,
emptyMode = "blank",
emptyText = "",
scale = 2,
locale,
currency = "EUR",
className,
}: AmountInputProps) {
// Hook de dinero para parseo/redondeo consistente con el resto de la app
const { parse, roundToScale } = useMoney({ locale, fallbackCurrency: currency 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]
);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number"
? value
: (parse(String(value)) ?? Number(String(value).replace(/[^\d.,\-]/g, "").replace(/\./g, "").replace(",", ".")));
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(numeric, scale);
return formatCurrencyNumber(n);
}, [value, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
// pasar de visual con símbolo → crudo
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const current =
parse(e.currentTarget.value) ??
(value === "" || value == null ? null : typeof value === "number" ? value : parse(String(value)));
setRaw(current !== null && current !== undefined ? String(current) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
setRaw(formatCurrencyNumber(rounded)); // vuelve a visual con símbolo
},
[isShowingEmptyValue, onChange, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (readOnly) return;
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") 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
},
[readOnly, parse, isShowingEmptyValue, raw, step, roundToScale, scale, onChange]
);
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
id={id}
aria-label={ariaLabel}
readOnly
tabIndex={-1}
onFocus={handleBlock}
onMouseDown={handleBlock}
onKeyDown={(e) => e.preventDefault()}
value={visualText}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
/>
);
}
return (
<input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
readOnly={readOnly}
placeholder={emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined}
value={raw}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -1,17 +1,17 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { Control, Controller } from "react-hook-form";
import { useTranslation } from '../../../i18n';
import { InvoiceItemFormData } from '../../../schemas';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountDTOInputField } from './amount-dto-input-field';
import { AmountInputField } from './amount-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
import { PercentageInputField } from './percentage-input-field';
import { QuantityInputField } from './quantity-input-field';
export type ItemRowProps = {
control: Control,
item: InvoiceItemFormData;
rowIndex: number;
isSelected: boolean;
isFirst: boolean;
@ -26,8 +26,8 @@ export type ItemRowProps = {
export const ItemRow = ({
control,
item,
rowIndex,
isSelected,
isFirst,
@ -71,11 +71,14 @@ export const ItemRow = ({
<textarea
{...field}
aria-label={t("form_fields.item.description.label")}
className='w-full resize-none bg-transparent p-0 pt-1.5 leading-5 min-h-8 focus:outline-none focus:bg-background'
className={cn(
"w-full bg-transparent p-0 pt-1.5 resize-none border-0 shadow-none h-8",
"hover:bg-background hover:border-ring hover:ring-ring/50 hover:ring-[2px] focus-within:resize-y",
)}
rows={1}
spellCheck
readOnly={readOnly}
onInput={(e) => {
onFocus={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
@ -87,7 +90,7 @@ export const ItemRow = ({
{/* qty */}
<TableCell className='text-right'>
<QuantityDTOInputField
<QuantityInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
@ -98,7 +101,7 @@ export const ItemRow = ({
{/* unit */}
<TableCell className='text-right'>
<AmountDTOInputField
<AmountInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
@ -110,7 +113,7 @@ export const ItemRow = ({
{/* discount */}
<TableCell className='text-right'>
<PercentageDTOInputField
<PercentageInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
@ -136,7 +139,7 @@ export const ItemRow = ({
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5'>
<HoverCardTotalsSummary rowIndex={rowIndex} >
<AmountDTOInputField
<AmountInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
@ -159,7 +162,7 @@ export const ItemRow = ({
onClick={onDuplicate}
disabled={readOnly}
aria-label='Duplicar fila'
className='h-8 w-8 self-start -translate-y-[1px]'
className='size-8 self-start -translate-y-[1px]'
>
<CopyIcon className='size-4' />
</Button>
@ -176,7 +179,7 @@ export const ItemRow = ({
onClick={onMoveUp}
disabled={readOnly || isFirst}
aria-label='Mover arriba'
className='h-8 w-8 self-start -translate-y-[1px]'
className='size-8 self-start -translate-y-[1px]'
>
<ArrowUpIcon className='size-4' />
</Button>
@ -190,7 +193,7 @@ export const ItemRow = ({
onClick={onMoveDown}
disabled={readOnly || isLast}
aria-label='Mover abajo'
className='h-8 w-8 self-start -translate-y-[1px]'
className='size-8 self-start -translate-y-[1px]'
>
<ArrowDownIcon className='size-4' />
</Button>
@ -204,7 +207,7 @@ export const ItemRow = ({
onClick={onRemove}
disabled={readOnly}
aria-label='Eliminar fila'
className='h-8 w-8 self-start -translate-y-[1px]'
className='size-8 self-start -translate-y-[1px]'
>
<Trash2Icon className='size-4' />
</Button>

View File

@ -1,5 +1,6 @@
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { CopyPlusIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
export const ItemsEditorToolbar = ({
@ -20,7 +21,11 @@ export const ItemsEditorToolbar = ({
onRemove?: () => void;
}) => {
const { t } = useTranslation();
// memoiza valores derivados
const hasSel = selectedIndexes.length > 0;
const selectedCount = useMemo(() => selectedIndexes.length, [selectedIndexes]);
return (
<nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
<div className="flex items-center gap-2">
@ -28,8 +33,14 @@ export const ItemsEditorToolbar = ({
{onAdd && (
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='outline' size='sm' onClick={onAdd} disabled={readOnly}>
<PlusIcon className='size-4 mr-1' />
<Button
type="button"
variant="outline"
size="sm"
onClick={onAdd}
disabled={readOnly}
>
<PlusIcon className="size-4 mr-1" />
{t("common.append_empty_row")}
</Button>
</TooltipTrigger>
@ -41,15 +52,15 @@ export const ItemsEditorToolbar = ({
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
size='sm'
variant='outline'
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={!hasSel || readOnly}
>
<CopyPlusIcon className='size-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
<CopyPlusIcon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
{t("common.duplicate_selected_rows")}
</span>
</Button>
@ -85,31 +96,36 @@ export const ItemsEditorToolbar = ({
<Separator orientation="vertical" className="mx-2" />
*/}
{onRemove && (<>
<Separator orientation="vertical" className="mx-2" />
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
size='sm'
variant='outline'
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={!hasSel || readOnly}
aria-label={t("common.remove_selected_rows")}
>
<Trash2Icon className='size-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
</>
{onRemove && (
<>
<Separator orientation="vertical" className="mx-2" />
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={!hasSel || readOnly}
aria-label={t("common.remove_selected_rows")}
>
<Trash2Icon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
{t("common.remove_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t("common.remove_selected_rows_tooltip")}
</TooltipContent>
</Tooltip>
</>
)}
</div>
<div className="flex items-center gap-2">
<p className='text-sm font-normal'>
{t("common.rows_selected", { count: selectedIndexes.length })}
<p className="text-sm font-normal">
{t("common.rows_selected", { count: selectedCount })}
</p>
</div>
</nav>

View File

@ -0,0 +1,173 @@
import { 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, useWatch } from "react-hook-form";
import { useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { LastCellTabHook } from './last-cell-tab-hook';
interface ItemsEditorProps {
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ onChange, readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const form = useFormContext();
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const {
selectedRows,
selectedIndexes,
selectAllState,
toggleRow,
setSelectAll,
clearSelection,
} = useRowSelection(tableNav.fa.fields.length);
const { control } = form;
const items = useWatch({ control: control, name: "items" });
// propagar cambios a componente padre
/*useEffect(() => {
onChange?.(items ?? []);
}, [items, onChange]);*/
const handleAdd = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const handleDuplicate = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveUp = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDown = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemove = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
const hasSelection = selectedIndexes.length > 0;
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
onDuplicate={() => selectedIndexes.forEach((i) => tableNav.duplicate(i))}
onMoveUp={() => selectedIndexes.forEach((i) => tableNav.moveUp(i))}
onMoveDown={() => [...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i))}
onRemove={() => {
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}} />
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<colgroup>
<col className='w-[1%]' /> {/* sel */}
<col className='w-[1%]' /> {/* # */}
<col className='w-[42%]' /> {/* description */}
<col className="w-[4%]" /> {/* qty */}
<col className="w-[10%]" /> {/* unit */}
<col className="w-[4%]" /> {/* discount */}
<col className="w-[16%]" /> {/* taxes */}
<col className="w-[8%]" /> {/* taxes2 */}
<col className="w-[12%]" /> {/* total */}
<col className='w-[10%]' /> {/* actions */}
</colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead>
<div className='h-5'>
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked) => setSelectAll(checked)}
/>
</div>
</TableHead>
<TableHead>#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="text-right">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead aria-hidden="true" />
</TableRow>
</TableHeader>
<TableBody className='text-sm'>
{tableNav.fa.fields.map((f, rowIndex) => (
<ItemRow
key={f.id}
control={control}
item={form.watch(`items.${rowIndex}`)}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === tableNav.fa.fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
onMoveUp={() => tableNav.moveUp(rowIndex)}
onMoveDown={() => tableNav.moveDown(rowIndex)}
onRemove={() => tableNav.remove(rowIndex)}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={9}>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{/* Navegación por TAB: último campo de la fila */}
<LastCellTabHook itemsLength={tableNav.fa.fields.length} onTabFromLast={tableNav.onTabFromLastCell} />
</div >
);
}

View File

@ -1,33 +1,34 @@
import { useRowSelection } from '@repo/rdx-ui/hooks';
import { CheckedState, useRowSelection } from '@repo/rdx-ui/hooks';
import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import * as React from "react";
import { useCallback } from 'react';
import { useFormContext } from "react-hook-form";
import { useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
import { LastCellTabHook } from './last-cell-tab-hook';
interface ItemsEditorProps {
value?: InvoiceItemFormData[];
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => {
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const form = useFormContext();
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { control } = form;
const { fieldArray: { fields } } = tableNav;
const {
selectedRows,
selectedIndexes,
@ -35,17 +36,37 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
toggleRow,
setSelectAll,
clearSelection,
} = useRowSelection(tableNav.fa.fields.length);
} = useRowSelection(fields.length);
const { control, watch } = form;
const handleAddSelection = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const handleDuplicateSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
// Emitir cambios a quien consuma el componente
React.useEffect(() => {
const sub = watch((v) => onChange?.(v.items ?? []));
return () => sub.unsubscribe();
}, [watch, onChange]);
const handleMoveUpSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDownSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemoveSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
return (
<div className="space-y-0">
@ -53,28 +74,23 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
onDuplicate={() => selectedIndexes.forEach((i) => tableNav.duplicate(i))}
onMoveUp={() => selectedIndexes.forEach((i) => tableNav.moveUp(i))}
onMoveDown={() => [...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i))}
onRemove={() => {
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}} />
onAdd={handleAddSelection}
onDuplicate={handleDuplicateSelection}
onMoveUp={handleMoveUpSelection}
onMoveDown={handleMoveDownSelection}
onRemove={handleRemoveSelection} />
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<colgroup>
<col className='w-[1%]' /> {/* sel */}
<col className='w-[1%]' /> {/* # */}
<col className='w-[42%]' /> {/* description */}
<col className="w-[4%]" /> {/* qty */}
<col className="w-[10%]" /> {/* unit */}
<col className="w-[4%]" /> {/* discount */}
<col className="w-[16%]" /> {/* taxes */}
<col className="w-[8%]" /> {/* taxes2 */}
<col className="w-[12%]" /> {/* total */}
<col className='w-[10%]' /> {/* actions */}
<col className='w-[1%]' />
<col className='w-[1%]' />
<col className='w-[42%]' />
<col className="w-[4%]" />
<col className="w-[10%]" />
<col className="w-[4%]" />
<col className="w-[16%]" />
<col className="w-[8%]" />
<col className="w-[12%]" />
</colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
@ -84,7 +100,7 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked) => setSelectAll(checked)}
onCheckedChange={(checked: CheckedState) => setSelectAll(checked)}
/>
</div>
</TableHead>
@ -100,15 +116,14 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
</TableHeader>
<TableBody className='text-sm'>
{tableNav.fa.fields.map((f, rowIndex) => (
{fields.map((f, rowIndex: number) => (
<ItemRow
key={f.id}
control={control}
item={form.watch(`items.${rowIndex}`)}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === tableNav.fa.fields.length - 1}
isLast={rowIndex === fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
@ -121,22 +136,18 @@ export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEdi
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={9}>
<TableCell colSpan={9} className='p-0 m-0'>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
{/* Navegación por TAB: último campo de la fila */}
<LastCellTabHook itemsLength={tableNav.fa.fields.length} onTabFromLast={tableNav.onTabFromLastCell} />
</div >
);
}

View File

@ -1,140 +0,0 @@
import { PercentageDTO } from '@erp/core';
import { usePercentage } from '@erp/core/hooks';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
type PercentageDTOEmptyMode = "blank" | "placeholder" | "value";
type PercentageDTOReadOnlyMode = "textlike-input" | "normal";
type PercentageDTOInputProps = {
value: PercentageDTO | null | undefined;
onChange: (next: PercentageDTO | null) => void;
readOnly?: boolean;
readOnlyMode?: PercentageDTOReadOnlyMode;
id?: string;
"aria-label"?: string;
className?: string;
step?: number; // incremento por flechas
emptyMode?: PercentageDTOEmptyMode;
emptyText?: string;
min?: number; // default 0
max?: number; // default 100
showSuffix?: boolean; // mostrar % en visual (blur)
};
const isEmptyDTO = (p?: PercentageDTO | null) =>
!p || p.value.trim() === "" || p.scale.trim() === "";
export function PercentageDTOInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Discount percentage",
className,
step = 0.1,
emptyMode = "blank",
emptyText = "",
min = 0,
max = 100,
showSuffix = true,
}: PercentageDTOInputProps) {
const { toNumber, fromNumber } = usePercentage();
const [raw, setRaw] = React.useState("");
const [focused, setFocused] = React.useState(false);
const isEmptyDTO = !value || !value.value || !value.scale;
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
const formatVisual = React.useCallback(() => {
if (!value || !value.value || !value.scale) return emptyMode === "value" ? emptyText : "";
const n = toNumber(value);
const txt = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
return showSuffix ? `${txt}%` : txt;
}, [value, toNumber, showSuffix, emptyMode, emptyText]);
React.useEffect(() => {
if (focused) return;
setRaw(formatVisual());
}, [formatVisual, focused]);
const parse = (s: string): number | null => {
const t = s.replace("%", "").trim();
if (!t) return null;
const n = Number(t.replace(",", "."));
return Number.isFinite(n) ? n : null;
};
const clamp2 = (n: number) => Math.min(Math.max(n, min), max);
// ── readOnly como INPUT que parece texto ────────────────────────────────
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
id={id}
aria-label={ariaLabel}
readOnly
tabIndex={-1}
onFocus={(e) => e.currentTarget.blur()}
onMouseDown={(e) => e.preventDefault()}
onKeyDown={(e) => e.preventDefault()}
value={formatVisual()}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
/>
);
}
// ── editable / readOnly normal ─────────────────────────────────────────
return (
<input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern={focused ? "[0-9]*[.,]?[0-9]*%?" : undefined}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 focus:bg-background",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className)}
readOnly={readOnly}
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); return; }
const n = parse(e.currentTarget.value) ?? (isEmptyDTO ? null : toNumber(value!));
setRaw(n !== null ? String(n) : "");
}}
onBlur={(e) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) { onChange(null); setRaw(emptyMode === "value" ? emptyText : ""); return; }
const n = parse(txt);
if (n === null) { onChange(null); setRaw(emptyMode === "value" ? emptyText : ""); return; }
const rounded = Math.round(clamp2(n) * 1e2) / 1e2;
onChange(fromNumber(rounded, 2));
const plain = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, minimumFractionDigits: Number.isInteger(rounded) ? 0 : 0, useGrouping: false }).format(rounded);
setRaw(showSuffix ? `${plain}%` : plain);
}}
onKeyDown={(e) => {
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
const inc = (e.shiftKey ? 10 : 1) * step;
const next = e.key === "ArrowUp" ? base + inc : base - inc;
const rounded = Math.round(clamp2(next) * 1e2) / 1e2;
onChange(fromNumber(rounded, 2));
setRaw(String(rounded)); // crudo durante edición
}
}}
/>
);
}

View File

@ -1,46 +1,28 @@
import { PercentageDTO } from '@erp/core';
import {
FormControl, FormDescription,
FormField, FormItem, FormLabel, FormMessage,
} from "@repo/shadcn-ui/components";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { PercentageDTOInput } from './percentage-dto-input';
import { PercentageInput, PercentageInputProps } from './percentage-input';
type BaseProps<T extends FieldValues> = {
type PercentageInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
readOnly?: boolean;
inputId?: string;
"aria-label"?: string;
className?: string;
step?: number;
emptyMode?: "blank" | "placeholder" | "value";
emptyText?: string;
min?: number;
max?: number;
showSuffix?: boolean;
};
} & Omit<PercentageInputProps, "value" | "onChange">
export function PercentageDTOInputField<T extends FieldValues>({
export function PercentageInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
readOnly = false,
inputId,
"aria-label": ariaLabel = "discount percentage",
className,
step = 0.1,
emptyMode = "blank",
emptyText = "",
min = 0,
max = 100,
showSuffix = true,
}: BaseProps<T>) {
...inputProps
}: PercentageInputFieldProps<T>) {
return (
<FormField
control={control}
@ -53,19 +35,11 @@ export function PercentageDTOInputField<T extends FieldValues>({
</FormLabel>
) : null}
<FormControl>
<PercentageDTOInput
<PercentageInput
id={inputId}
aria-label={ariaLabel}
value={field.value as PercentageDTO | null}
value={field.value}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
min={min}
max={max}
showSuffix={showSuffix}
className={className}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}

View File

@ -0,0 +1,218 @@
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
import { InputEmptyMode, InputReadOnlyMode } from './quantity-input';
export type PercentageInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.1
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto en vacío para value/placeholder
scale?: number; // decimales; default 2
min?: number; // default 0 (p. ej. descuentos)
max?: number; // default 100
showSuffix?: boolean; // “%” en visual; default true
locale?: string; // para formateo numérico
className?: string;
};
export function PercentageInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Percentage",
step = 0.1,
emptyMode = "blank",
emptyText = "",
scale = 2,
min = 0,
max = 100,
showSuffix = true,
locale,
className,
}: PercentageInputProps) {
const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim();
const parseLocaleNumber = React.useCallback((raw: string): number | null => {
if (!raw) return null;
const s = stripNumberish(raw);
if (!s) return null;
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let normalized = s;
if (lastComma > -1 && lastDot > -1) {
normalized = lastComma > lastDot ? s.replace(/\./g, "").replace(",", ".") : s.replace(/,/g, "");
} else if (lastComma > -1) {
normalized = s.replace(",", ".");
}
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}, []);
const roundToScale = React.useCallback((n: number, sc: number) => {
const f = 10 ** sc;
return Math.round(n * f) / f;
}, []);
const clamp = React.useCallback(
(n: number) => Math.min(Math.max(n, min), max),
[min, max]
);
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const formatVisual = React.useCallback(
(n: number) => {
const txt = new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
return showSuffix ? `${txt}%` : txt;
},
[locale, scale, showSuffix]
);
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number" ? value : parseLocaleNumber(String(value));
if (!Number.isFinite(numeric as number)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(clamp(numeric as number), scale);
return formatVisual(n);
}, [value, emptyMode, emptyText, parseLocaleNumber, roundToScale, clamp, scale, formatVisual]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
},
[]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parseLocaleNumber(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parseLocaleNumber(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parseLocaleNumber, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim().replace("%", "");
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const parsed = parseLocaleNumber(txt);
if (parsed === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(clamp(parsed), scale);
onChange(rounded);
setRaw(formatVisual(rounded)); // vuelve a visual con %
},
[isShowingEmptyValue, onChange, emptyMode, emptyText, parseLocaleNumber, roundToScale, clamp, scale, formatVisual]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (readOnly) return;
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") 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
},
[readOnly, parseLocaleNumber, isShowingEmptyValue, raw, step, clamp, roundToScale, scale, onChange]
);
// Bloquear foco/edición en modo texto
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
id={id}
aria-label={ariaLabel}
readOnly
tabIndex={-1}
onFocus={handleBlock}
onMouseDown={handleBlock}
onKeyDown={(e) => e.preventDefault()}
value={visualText}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
/>
);
}
return (
<input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*%?"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
readOnly={readOnly}
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
value={raw}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -1,231 +0,0 @@
import { QuantityDTO } from '@erp/core';
import { isEmptyQuantityDTO, useQuantity } from '@erp/core/hooks';
import { cn } from '@repo/shadcn-ui/lib/utils';
import * as React from "react";
export type QuantityDTOEmptyMode = "blank" | "placeholder" | "value";
type QuantityDTOSuffixMap = { one: string; other: string; zero?: string };
type QuantityDTOReadOnlyMode = "textlike-input" | "normal";
type QuantityDTOInputProps = {
value: QuantityDTO | null | undefined;
onChange: (next: QuantityDTO | null) => void;
readOnly?: boolean;
readOnlyMode?: QuantityDTOReadOnlyMode;
id?: string;
className?: string;
"aria-label"?: string;
step?: number; // incremento por teclas de cursor
emptyMode?: QuantityDTOEmptyMode; // cómo mostrar cuando el DTO está vacío
emptyText?: string; // texto a mostrar (o placeholder)
scale?: number; // fallback si no viene en DTO
locale?: string; // p.ej. "es-ES" (default del navegador)
displaySuffix?: QuantityDTOSuffixMap | ((n: number) => string); // "caja"/"cajas" o función
nbspBeforeSuffix?: boolean; // separador no rompible antes del sufijo (default true)
};
export function QuantityDTOInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
className,
"aria-label": ariaLabel = "Quantity",
step = 1,
emptyMode = "blank",
emptyText = "",
scale,
locale,
displaySuffix,
nbspBeforeSuffix = true,
}: QuantityDTOInputProps) {
const { parse, fromNumber, toNumber, roundToScale, defaultScale, formatPlain } =
useQuantity({ defaultScale: scale ?? 2, min: 0 });
const [raw, setRaw] = React.useState("");
const [focused, setFocused] = React.useState(false);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
const sc = Number(value?.scale ?? (scale ?? defaultScale));
const plural = React.useMemo(() => new Intl.PluralRules(locale ?? undefined), [locale]);
const suffixFor = React.useCallback((n: number): string => {
if (!displaySuffix) return "";
if (typeof displaySuffix === "function") return displaySuffix(n);
const cat = plural.select(Math.abs(n));
if (n === 0 && displaySuffix.zero) return displaySuffix.zero;
return displaySuffix[cat as "one" | "other"] ?? displaySuffix.other;
}, [displaySuffix, plural]);
const visualText = React.useMemo(() => {
if (isEmptyQuantityDTO(value)) return emptyMode === "value" ? emptyText : "";
const n = toNumber(value!);
const numTxt = formatPlain(value!);
const suf = suffixFor(n);
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
}, [value, toNumber, formatPlain, suffixFor, nbspBeforeSuffix, emptyMode, emptyText]);
const formatNumber = React.useCallback((value: number, locale: string, sc: number) => {
return new Intl.NumberFormat(locale, {
maximumFractionDigits: sc,
minimumFractionDigits: 0,
useGrouping: false,
}).format(value);
}, []);
const numberFmt = React.useMemo(
() => new Intl.NumberFormat(locale, { maximumFractionDigits: sc, minimumFractionDigits: 0, useGrouping: false }),
[locale, sc]
);
const formatDisplay = React.useCallback(
(value: number) => {
const numTxt = numberFmt.format(value);
const suf = suffixFor(value);
return suf
? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}`
: numTxt;
},
[numberFmt, suffixFor, nbspBeforeSuffix]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
// Casos vacíos
if (txt === "" || isShowingEmptyValue) {
React.startTransition(() => {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
});
return;
}
const n = parse(txt);
if (n === null) {
React.startTransition(() => {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
});
return;
}
const rounded = roundToScale(n, sc);
const formatted = formatDisplay(rounded);
// Actualiza en transición concurrente (no bloquea UI)
React.startTransition(() => {
onChange(fromNumber(rounded, sc));
setRaw(formatted);
});
},
[
sc,
parse,
formatDisplay,
roundToScale,
fromNumber,
onChange,
emptyMode,
emptyText,
isShowingEmptyValue,
]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
const val = e.currentTarget.value;
// Si muestra el placeholder "vacío lógico", limpiar
if (emptyMode === "value" && val === emptyText) {
setRaw("");
return;
}
// Intenta parsear lo visible, o usa valor actual si no hay parse
const parsed =
parse(val) ??
(!isEmptyQuantityDTO(value) ? toNumber(value!) : null);
setRaw(parsed !== null ? String(parsed) : "");
},
[emptyMode, emptyText, parse, value, toNumber]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (readOnly) return;
const { key, shiftKey } = e;
if (key !== "ArrowUp" && key !== "ArrowDown") return;
e.preventDefault();
// Base numérica a partir del texto actual
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
// Cálculo de incremento/decremento
const delta = (shiftKey ? 10 : 1) * step * (key === "ArrowUp" ? 1 : -1);
const next = roundToScale(base + delta, sc);
React.startTransition(() => {
onChange(fromNumber(next, sc));
setRaw(String(next));
});
},
[readOnly, parse, raw, isShowingEmptyValue, step, sc, onChange, fromNumber, roundToScale]
);
React.useEffect(() => {
if (focused) return;
setRaw(visualText);
}, [visualText, focused]);
// ── readOnly como INPUT que parece texto
if (readOnly && readOnlyMode === "textlike-input") {
return (
<input
id={id}
aria-label={ariaLabel}
readOnly
tabIndex={-1}
onFocus={(e) => e.currentTarget.blur()}
onMouseDown={(e) => e.preventDefault()}
onKeyDown={(e) => e.preventDefault()}
value={visualText}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
/>
);
}
// ── editable / readOnly normal (con foco)
return (
<input
id={id}
inputMode="decimal"
pattern={focused ? "[0-9]*[.,]?[0-9]*" : undefined}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
placeholder={emptyMode === "placeholder" && isEmptyQuantityDTO(value) ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -1,38 +1,28 @@
import { QuantityDTO } from '@erp/core';
import { CommonInputProps } from '@repo/rdx-ui/components';
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@repo/shadcn-ui/components';
import { Control, FieldPath, FieldValues } from 'react-hook-form';
import { QuantityDTOEmptyMode, QuantityDTOInput } from './quantity-dto-input';
import { QuantityInput, QuantityInputProps } from './quantity-input';
type QuantityDTOInputFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
type QuantityInputFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
inputId?: string;
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
required?: boolean;
readOnly?: boolean;
step?: number;
inputId?: string;
"aria-label"?: string;
emptyMode?: QuantityDTOEmptyMode;
emptyText?: string;
}
} & Omit<QuantityInputProps, "value" | "onChange">
export function QuantityDTOInputField<TFormValues extends FieldValues>({
export function QuantityInputField<TFormValues extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
readOnly = false,
step = 1,
inputId,
"aria-label": ariaLabel = "quantity",
emptyMode = "blank",
emptyText = "",
}: QuantityDTOInputFieldProps<TFormValues>) {
...inputProps
}: QuantityInputFieldProps<TFormValues>) {
return (
<FormField
@ -46,15 +36,11 @@ export function QuantityDTOInputField<TFormValues extends FieldValues>({
</FormLabel>
) : null}
<FormControl>
<QuantityDTOInput
<QuantityInput
id={inputId}
aria-label={ariaLabel}
value={field.value as QuantityDTO | null}
value={field.value}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}

View File

@ -0,0 +1,210 @@
// QuantityNumberInput.tsx — valor primitivo (number | "" | string numérica)
// Comentarios en español. TS estricto.
import { useQuantity } from '@erp/core/hooks';
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
export type InputEmptyMode = "blank" | "placeholder" | "value";
export type InputReadOnlyMode = "textlike-input" | "normal";
export type InputSuffixMap = { one: string; other: string; zero?: string };
export type QuantityInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
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
locale?: string; // para plural/sufijo y formateo
className?: string;
// Sufijo solo en visual, p.ej. {one:"caja", other:"cajas"}
displaySuffix?: InputSuffixMap | ((n: number) => string);
nbspBeforeSuffix?: boolean; // separador no rompible
};
export function QuantityInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Quantity",
step = 1,
emptyMode = "blank",
emptyText = "",
scale = 2,
locale,
className,
displaySuffix,
nbspBeforeSuffix = true,
}: QuantityInputProps) {
const { parse, roundToScale } = useQuantity({ defaultScale: scale });
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const plural = React.useMemo(() => new Intl.PluralRules(locale ?? undefined), [locale]);
const suffixFor = React.useCallback((n: number): string => {
if (!displaySuffix) return "";
if (typeof displaySuffix === "function") return displaySuffix(n);
const cat = plural.select(Math.abs(n));
if (n === 0 && displaySuffix.zero) return displaySuffix.zero;
return displaySuffix[cat as "one" | "other"] ?? displaySuffix.other;
}, [displaySuffix, plural]);
const formatNumber = React.useCallback((n: number) => {
return new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
}, [locale, scale]);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value === null || value === undefined) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
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;
}, [value, emptyMode, emptyText, parse, roundToScale, scale, formatNumber, suffixFor, nbspBeforeSuffix]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
},
[]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parse(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parse(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
const numTxt = formatNumber(rounded);
const suf = suffixFor(rounded);
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
},
[isShowingEmptyValue, onChange, emptyMode, emptyText, parse, roundToScale, scale, formatNumber, suffixFor, nbspBeforeSuffix]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (readOnly) return;
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") 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
},
[readOnly, parse, isShowingEmptyValue, raw, step, roundToScale, scale, onChange]
);
// ── READ-ONLY como input que parece texto ───────────────────────────────
if (readOnly && readOnlyMode === "textlike-input") {
const handleBlockFocus = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
const handleBlockKey = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
}, []);
return (
<input
id={id}
aria-label={ariaLabel}
readOnly
tabIndex={-1}
onFocus={handleBlockFocus}
onMouseDown={handleBlockFocus}
onKeyDown={handleBlockKey}
value={visualText}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
/>
);
}
// ── Editable / readOnly normal ──────────────────────────────────────────
return (
<input
id={id}
aria-label={ariaLabel}
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
readOnly={readOnly}
placeholder={emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined}
value={raw}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}

View File

@ -1,12 +1,16 @@
import { TaxCatalogProvider } from '@erp/core';
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
export type InvoiceContextValue = {
company_id: string;
invoice_id: string;
status: string;
currency_code: string;
language_code: string;
is_proforma: boolean;
taxCatalog: TaxCatalogProvider;
changeLanguage: (lang: string) => void;
changeCurrency: (currency: string) => void;
changeIsProforma: (value: boolean) => void;
@ -15,15 +19,18 @@ export type InvoiceContextValue = {
const InvoiceContext = createContext<InvoiceContextValue | null>(null);
export interface InvoiceProviderParams {
taxCatalog: TaxCatalogProvider;
invoice_id: string;
company_id: string;
status: string; // default "draft"
language_code?: string; // default "es"
currency_code?: string; // default "EUR"
is_proforma?: boolean; // default 'true'
children: React.ReactNode;
}
export const InvoiceProvider = ({ company_id, status: initialStatus = "draft", language_code: initialLang = "es",
export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, company_id, status: initialStatus = "draft", language_code: initialLang = "es",
currency_code: initialCurrency = "EUR",
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
@ -32,6 +39,7 @@ export const InvoiceProvider = ({ company_id, status: initialStatus = "draft", l
const [currency_code, setCurrency] = useState(initialCurrency);
const [is_proforma, setIsProforma] = useState(initialProforma);
const [status] = useState(initialStatus);
const [taxCatalog] = useState(initialTaxCatalog);
// Callbacks memoizados
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
@ -41,17 +49,20 @@ export const InvoiceProvider = ({ company_id, status: initialStatus = "draft", l
const value = useMemo<InvoiceContextValue>(() => {
return {
invoice_id,
company_id,
status,
language_code,
currency_code,
is_proforma,
taxCatalog,
changeLanguage: setLanguageMemo,
changeCurrency: setCurrencyMemo,
changeIsProforma: setIsProformaMemo
}
}, [company_id, language_code, currency_code, is_proforma, setLanguageMemo, setCurrencyMemo, setIsProformaMemo]);
}, [company_id, invoice_id, status, language_code, currency_code, is_proforma, taxCatalog, setLanguageMemo, setCurrencyMemo, setIsProformaMemo]);
return <InvoiceContext.Provider value={value}>{children}</InvoiceContext.Provider>;
};

View File

@ -0,0 +1,58 @@
import Dinero, { Currency } from "dinero.js";
export interface InvoiceHeaderCalcInput {
subtotal_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_amount: number;
total_amount: number;
}
export interface InvoiceHeaderCalcResult {
subtotal_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_amount: number;
total_amount: number;
}
/**
* Agrega los importes de todas las líneas y recalcula los totales generales
* con precisión financiera (sumas exactas en céntimos).
*/
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,
});
let subtotal = toDinero(0);
let discount = toDinero(0);
let taxable = toDinero(0);
let taxes = toDinero(0);
let total = toDinero(0);
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));
}
const toNum = (d: Dinero.Dinero) => d.toUnit();
return {
subtotal_amount: toNum(subtotal),
discount_amount: toNum(discount),
taxable_amount: toNum(taxable),
taxes_amount: toNum(taxes),
total_amount: toNum(total),
};
}

View File

@ -0,0 +1,72 @@
import { TaxCatalogProvider } from "@erp/core";
import Dinero, { Currency } from "dinero.js";
export interface InvoiceItemCalcInput {
quantity?: string; // p.ej. "3.5"
unit_amount?: string; // p.ej. "125.75"
discount_percentage?: string; // p.ej. "10" (=> 10%)
tax_codes: string[]; // ["iva_21", ...]
}
export interface InvoiceItemCalcResult {
subtotal_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_amount: number;
total_amount: number;
}
/**
* Cálculo financiero exacto por línea de factura.
* Usa Dinero.js (base 10^2 y round half up) resultados seguros en céntimos.
*/
export function calculateInvoiceItemAmounts(
item: InvoiceItemCalcInput,
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 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;
// Subtotal = cantidad × precio unitario
const subtotal = toDinero(qty * unit);
// Descuento = subtotal × (pct / 100)
const discount = subtotal.percentage(pct);
// Base imponible
const taxable = subtotal.subtract(discount);
// Impuestos acumulados
let taxes = toDinero(0);
for (const code of item.tax_codes ?? []) {
const tax = taxCatalog.findByCode(code);
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 total = taxable.add(taxes);
// Devuelve valores desescalados (número con 2 decimales exactos)
const toNum = (d: Dinero.Dinero) => d.toUnit();
return {
subtotal_amount: toNum(subtotal),
discount_amount: toNum(discount),
taxable_amount: toNum(taxable),
taxes_amount: toNum(taxes),
total_amount: toNum(total),
};
}

View File

@ -0,0 +1,2 @@
export * from "./calculate-invoice-header-amounts";
export * from "./calculate-invoice-item-amounts";

View File

@ -1,165 +1,186 @@
import { areMoneyDTOEqual } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { TaxCatalogProvider } from "@erp/core";
import * as React from "react";
import { UseFormReturn } from "react-hook-form";
import {
InvoiceItemCalcResult,
calculateInvoiceHeaderAmounts,
calculateInvoiceItemAmounts,
} from "../../domain";
import { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
export type UseInvoiceAutoRecalcParams = {
currency_code: string;
taxCatalog: TaxCatalogProvider;
};
/**
* Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes.
* Adaptado a formulario con números planos (no DTOs).
*/
export function useInvoiceAutoRecalc(form: UseFormReturn<InvoiceFormData>) {
export function useInvoiceAutoRecalc(
form: UseFormReturn<InvoiceFormData>,
params: UseInvoiceAutoRecalcParams
) {
const {
watch,
setValue,
getValues,
trigger,
formState: { isDirty, isLoading, isSubmitting },
} = form;
const moneyHelper = useMoney();
const qtyHelper = useQuantity();
const pctHelper = usePercentage();
const { currency_code, taxCatalog } = params;
// Cálculo de una línea
// Cache de los totales de cada línea de factura
const itemCache = React.useRef<Map<number, ReturnType<typeof calculateInvoiceItemAmounts>>>(
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) => {
if (!item) {
const zero = moneyHelper.fromNumber(0);
return {
subtotalDTO: zero,
discountAmountDTO: zero,
taxableBaseDTO: zero,
taxesDTO: zero,
totalDTO: zero,
};
}
const sanitizeString = (v?: number | string) =>
v && !Number.isNaN(Number(v)) ? String(v) : "0";
// Subtotal = unit_amount × quantity
const subtotalDTO = moneyHelper.multiply(item.unit_amount, qtyHelper.toNumber(item.quantity));
// Descuento = subtotal × (discount_percentage / 100)
const discountDTO = moneyHelper.percentage(
subtotalDTO,
pctHelper.toNumber(item.discount_percentage)
return calculateInvoiceItemAmounts(
{
quantity: sanitizeString(item.quantity),
unit_amount: sanitizeString(item.unit_amount),
discount_percentage: sanitizeString(item.discount_percentage),
tax_codes: item.tax_codes,
},
currency_code,
taxCatalog
);
// Base imponible = subtotal descuento
const taxableBaseDTO = moneyHelper.sub(subtotalDTO, discountDTO);
// Impuestos (placeholder: se integrará con tax catalog)
const taxesDTO = moneyHelper.fromNumber(0);
// Total = base imponible + impuestos
const totalDTO = moneyHelper.add(taxableBaseDTO, taxesDTO);
return {
subtotalDTO,
discountAmountDTO: discountDTO,
taxableBaseDTO,
taxesDTO,
totalDTO,
};
},
[moneyHelper, qtyHelper, pctHelper]
[taxCatalog, currency_code]
);
// Cálculo de los totales de la factura a partir de los conceptos
// 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[]) => {
let subtotalDTO = moneyHelper.fromNumber(0);
let discountTotalDTO = moneyHelper.fromNumber(0);
let taxableBaseDTO = moneyHelper.fromNumber(0);
let taxesDTO = moneyHelper.fromNumber(0);
let totalDTO = moneyHelper.fromNumber(0);
for (const item of items) {
const t = calculateItemTotals(item);
subtotalDTO = moneyHelper.add(subtotalDTO, t.subtotalDTO);
discountTotalDTO = moneyHelper.add(discountTotalDTO, t.discountAmountDTO);
taxableBaseDTO = moneyHelper.add(taxableBaseDTO, t.taxableBaseDTO);
taxesDTO = moneyHelper.add(taxesDTO, t.taxesDTO);
totalDTO = moneyHelper.add(totalDTO, t.totalDTO);
}
return {
subtotalDTO,
discountTotalDTO,
taxableBaseDTO,
taxesDTO,
totalDTO,
};
},
[moneyHelper, calculateItemTotals]
);
// Suscribirse a cambios del formulario
React.useEffect(() => {
if (!isDirty || isLoading || isSubmitting) {
return;
}
const subscription = watch((formData, { name, type }) => {
if (!formData?.items?.length) return;
// 1. Si cambia una línea completa (add/remove/move)
if (name === "items" && type === "change") {
formData.items.forEach((item, i) => {
if (!item) return;
const typedItem = item as InvoiceItemFormData;
const totals = calculateItemTotals(typedItem);
const current = getValues(`items.${i}.total_amount`);
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
setValue(`items.${i}.total_amount`, totals.totalDTO, {
shouldDirty: true,
shouldValidate: false,
});
}
const lines = items
.filter((i) => !i.is_non_valued)
.map((i) => {
const totals = calculateItemTotals(i);
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,
};
});
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as InvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
return calculateInvoiceHeaderAmounts(lines, currency_code);
},
[calculateItemTotals, currency_code]
);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
setValue("discount_amount", totalsGlobal.discountTotalDTO);
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
setValue("taxes_amount", totalsGlobal.taxesDTO);
setValue("total_amount", totalsGlobal.totalDTO);
// Suscripción reactiva a cambios del formulario
React.useEffect(() => {
if (!isDirty || isLoading || isSubmitting) return;
const subscription = watch(async (formData, { name, type }) => {
const items = (formData?.items || []) as InvoiceItemFormData[];
if (items.length === 0) return;
// Detectar cambios en la cabecera
if (name === "discount_percentage") {
// Recalcular totales de factura
const invoiceTotals = calculateInvoiceTotals(items);
// Cabecera
setInvoiceTotals(form, invoiceTotals);
// 3) valida una vez (opcional)
await trigger([
"subtotal_amount",
"discount_amount",
"taxable_amount",
"taxes_amount",
"total_amount",
]);
}
// 2. Si cambia un campo dentro de un concepto
// 2. Cambio puntual de una línea
if (name?.startsWith("items.") && type === "change") {
const index = Number(name.split(".")[1]);
const fieldName = name.split(".")[2];
const field = name.split(".")[2];
if (["quantity", "unit_amount", "discount_percentage"].includes(fieldName)) {
const typedItem = formData.items[index] as InvoiceItemFormData;
if (!typedItem) return;
if (["quantity", "unit_amount", "discount_percentage", "tax_codes"].includes(field)) {
const item = items[index] as InvoiceItemFormData;
const prevTotals = itemCache.current.get(index);
const newTotals = calculateItemTotals(item);
// Recalcular línea
const totals = calculateItemTotals(typedItem);
const current = getValues(`items.${index}.total_amount`);
// Si no hay cambios en los totales, no tocamos nada
const itemHasChanges =
prevTotals && JSON.stringify(prevTotals) !== JSON.stringify(newTotals);
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
setValue(`items.${index}.total_amount`, totals.totalDTO, {
shouldDirty: true,
shouldValidate: false,
});
if (!itemHasChanges) {
return;
}
// Recalcular importes totales de la factura y
// actualizar valores calculados.
const typedItems = formData.items as InvoiceItemFormData[];
const totalsGlobal = calculateInvoiceTotals(typedItems);
// El total de esta línea ha cambiado => actualizamos la cache
itemCache.current.set(index, newTotals);
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
setValue("discount_amount", totalsGlobal.discountTotalDTO);
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
setValue("taxes_amount", totalsGlobal.taxesDTO);
setValue("total_amount", totalsGlobal.totalDTO);
// Actualizar los campos de esta línea
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;
console.log(invoiceTotals);
// Cabecera
setInvoiceTotals(form, invoiceTotals);
// 3) valida una vez (opcional)
await trigger([
"items",
"subtotal_amount",
"discount_amount",
"taxable_amount",
"taxes_amount",
"total_amount",
]);
}
}
});
@ -167,12 +188,70 @@ export function useInvoiceAutoRecalc(form: UseFormReturn<InvoiceFormData>) {
return () => subscription.unsubscribe();
}, [
watch,
isDirty,
isLoading,
isSubmitting,
setValue,
getValues,
isLoading,
isSubmitting,
calculateItemTotals,
calculateInvoiceTotals,
]);
}
// Ayudante para rellenar los importes de una línea
function setInvoiceItemTotals(
form: UseFormReturn<InvoiceFormData>,
index: number,
newTotals: InvoiceItemCalcResult
) {
const { setValue } = form;
setValue(`items.${index}.subtotal_amount`, newTotals.subtotal_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.discount_amount`, newTotals.discount_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.taxable_amount`, newTotals.taxable_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.taxes_amount`, newTotals.taxes_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.total_amount`, newTotals.total_amount, {
shouldDirty: true,
shouldValidate: false,
});
}
// Ayudante para actualizar los importes de la cabecera
function setInvoiceTotals(
form: UseFormReturn<InvoiceFormData>,
invoiceTotals: ReturnType<typeof calculateInvoiceHeaderAmounts>
) {
const { setValue } = form;
setValue("subtotal_amount", invoiceTotals.subtotal_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("discount_amount", invoiceTotals.discount_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("taxable_amount", invoiceTotals.taxable_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("taxes_amount", invoiceTotals.taxes_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("total_amount", invoiceTotals.total_amount, {
shouldDirty: true,
shouldValidate: false,
});
}

View File

@ -1,11 +1,11 @@
import * as React from "react";
import { FieldValues, UseFormReturn, useFieldArray } from "react-hook-form";
type UseItemsTableNavigationOptions = {
name: string; // Ruta del array, p.ej. "items"
export interface UseItemsTableNavigationOptions {
name: string;
createEmpty: () => Record<string, unknown>;
firstEditableField?: string; // Primer campo editable para enfocar al crear/ir a la fila siguiente (p.ej. "description")
};
firstEditableField?: string;
}
export function useItemsTableNavigation(
form: UseFormReturn<FieldValues>,
@ -14,15 +14,28 @@ export function useItemsTableNavigation(
const { control, getValues, setFocus } = form;
const fa = useFieldArray({ control, name });
// Desestructurar para evitar recreaciones
const { append, insert, remove: faRemove, move } = fa;
// Ref estable para getValues
const getValuesRef = React.useRef(getValues);
getValuesRef.current = getValues;
const length = React.useCallback(() => {
const arr = getValues(name) as unknown[];
const arr = getValuesRef.current(name) as unknown[];
return Array.isArray(arr) ? arr.length : 0;
}, [getValues, name]);
}, [name]);
const focusRowFirstField = React.useCallback(
(rowIndex: number) => {
queueMicrotask(() => {
setFocus(`${name}.${rowIndex}.${firstEditableField}` as any, { shouldSelect: true });
try {
setFocus(`${name}.${rowIndex}.${firstEditableField}` as any, {
shouldSelect: true,
});
} catch {
// el campo aún no está montado
}
});
},
[name, firstEditableField, setFocus]
@ -31,50 +44,49 @@ export function useItemsTableNavigation(
const addEmpty = React.useCallback(
(atEnd = true, index?: number, initial?: Record<string, unknown>) => {
const row = { ...createEmpty(), ...(initial ?? {}) };
if (!atEnd && typeof index === "number") fa.insert(index, row);
else fa.append(row);
if (!atEnd && typeof index === "number") insert(index, row);
else append(row);
},
[fa, createEmpty]
[append, insert, createEmpty]
);
const duplicate = React.useCallback(
(i: number) => {
const curr = getValues(`${name}.${i}`) as Record<string, unknown> | undefined;
const curr = getValuesRef.current(`${name}.${i}`) as Record<string, unknown> | undefined;
if (!curr) return;
const clone =
typeof structuredClone === "function"
? structuredClone(curr)
: JSON.parse(JSON.stringify(curr));
// RHF añade un id interno en fields; por si acaso: crear un objeto sin la propiedad id
const { id: _id, ...sanitized } = clone as Record<string, unknown>;
fa.insert(i + 1, sanitized);
const { id: _id, ...sanitized } = clone;
insert(i + 1, sanitized);
},
[fa, getValues, name]
[insert, name]
);
const remove = React.useCallback(
(i: number) => {
if (i < 0 || i >= length()) return;
fa.remove(i);
faRemove(i);
},
[fa, length]
[faRemove, length]
);
const moveUp = React.useCallback(
(i: number) => {
if (i <= 0) return;
fa.move(i, i - 1);
move(i, i - 1);
},
[fa]
[move]
);
const moveDown = React.useCallback(
(i: number) => {
const len = length();
if (i < 0 || i >= len - 1) return;
fa.move(i, i + 1);
move(i, i + 1);
},
[fa, length]
[move, length]
);
const onTabFromLastCell = React.useCallback(
@ -99,7 +111,7 @@ export function useItemsTableNavigation(
);
return {
fa, // { fields, append, remove, insert, move, ... }
fieldArray: fa, // { fields, append, remove, insert, move, ... }
addEmpty,
duplicate,
remove,

View File

@ -0,0 +1,117 @@
import {
FormCommitButtonGroup,
UnsavedChangesProvider,
useHookForm
} from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { FilePenIcon } from "lucide-react";
import { useMemo } from 'react';
import { FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import {
CustomerInvoiceEditForm,
PageHeader
} from "../../components";
import { useInvoiceContext } from '../../context';
import { useInvoiceAutoRecalc, useUpdateCustomerInvoice } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
CustomerInvoice,
InvoiceFormData,
InvoiceFormSchema,
defaultCustomerInvoiceFormData,
invoiceDtoToFormAdapter
} from "../../schemas";
export type InvoiceUpdateCompProps = {
invoice: CustomerInvoice,
}
export const InvoiceUpdateComp = ({
invoice: invoiceData,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { invoice_id } = useInvoiceContext(); // ahora disponible desde el inicio
const context = useInvoiceContext();
const isPending = !invoiceData;
const {
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomerInvoice();
const initialValues = useMemo(() => {
return invoiceData
? invoiceDtoToFormAdapter.fromDto(invoiceData, context)
: defaultCustomerInvoiceFormData
}, [invoiceData, context, defaultCustomerInvoiceFormData])
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
initialValues,
disabled: !invoiceData || isUpdating
});
useInvoiceAutoRecalc(form, context);
const handleSubmit = (formData: InvoiceFormData) => {
mutate(
{ id: invoice_id, data: formData },
{
onSuccess: () => showSuccessToast(t("pages.update.successTitle")),
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
}
);
};
const handleReset = () =>
form.reset((invoiceData as unknown as InvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<InvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
console.log("InvoiceUpdateComp")
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
icon={<FilePenIcon className='size-12 text-primary' aria-hidden />}
rightSlot={
<FormCommitButtonGroup
isLoading={isPending}
submit={{ formId: "invoice-update-form", disabled: isPending }}
cancel={{ to: "/customer-invoices/list" }}
onBack={() => navigate(-1)}
/>
}
/>
</AppHeader>
<AppContent>
<FormProvider {...form}>
<CustomerInvoiceEditForm
formId="invoice-update-form"
onSubmit={handleSubmit}
onError={handleError}
className="max-w-full"
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

@ -1,35 +1,69 @@
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { SpainTaxCatalogProvider } from '@erp/core';
import {
FormCommitButtonGroup,
UnsavedChangesProvider,
useHookForm,
useUrlParamId,
useUrlParamId
} from "@erp/core/hooks";
import { ErrorAlert, NotFoundCard } from "@erp/customers/components";
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { FilePenIcon } from "lucide-react";
import { FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { useMemo } from 'react';
import {
CustomerInvoiceEditForm,
CustomerInvoiceEditorSkeleton,
PageHeader,
CustomerInvoiceEditorSkeleton
} from "../../components";
import { InvoiceProvider } from '../../context';
import { useInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
import { useInvoiceQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
InvoiceFormData,
InvoiceFormSchema,
defaultCustomerInvoiceFormData,
invoiceDtoToFormAdapter
} from "../../schemas";
import { InvoiceUpdateComp } from './invoice-update-comp';
export const InvoiceUpdatePage = () => {
const invoice_id = useUrlParamId();
const { t } = useTranslation();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
const invoiceQuery = useInvoiceQuery(invoice_id, { enabled: !!invoice_id });
const { data: invoiceData, isLoading, isError, error,
} = invoiceQuery;
console.log("InvoiceUpdatePage");
if (isLoading) {
return <CustomerInvoiceEditorSkeleton />;
}
if (isError || !invoiceData) {
return (
<AppContent>
<ErrorAlert
title={t("pages.update.loadErrorTitle")}
message={(error as Error)?.message || "Error al cargar la factura"}
/>
<BackHistoryButton />
</AppContent>
);
}
// Monta el contexto aquí, así todo lo que esté dentro puede usar hooks
return (
<InvoiceProvider
invoice_id={invoice_id!}
taxCatalog={taxCatalog}
company_id={invoiceData.company_id}
status={invoiceData.status}
language_code={invoiceData.language_code}
currency_code={invoiceData.currency_code}
>
<InvoiceUpdateComp invoice={invoiceData} />
</InvoiceProvider>
);
};
/*
const invoiceId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), [])
// 1) Estado de carga de la factura (query)
const {
@ -47,16 +81,21 @@ export const InvoiceUpdatePage = () => {
error: updateError,
} = useUpdateCustomerInvoice();
const context = useInvoiceContext();
// 3) Form hook
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
defaultValues: defaultCustomerInvoiceFormData,
values: invoiceData ? invoiceDtoToFormAdapter.fromDto(invoiceData) : undefined,
values: invoiceData ? invoiceDtoToFormAdapter.fromDto(invoiceData, taxCatalog) : undefined,
disabled: isUpdating,
});
// 4) Activa recálculo automático de los totales de la factura cuando hay algún cambio en importes
// useInvoiceAutoRecalc(form);
useInvoiceAutoRecalc(form, {
taxCatalog,
currency_code: invoiceData?.currency_code || 'EUR'
});
const handleSubmit = (formData: InvoiceFormData) => {
const { dirtyFields } = form.formState;
@ -85,17 +124,7 @@ export const InvoiceUpdatePage = () => {
);
};
const handleReset = () =>
form.reset((invoiceData as unknown as InvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<InvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
if (isLoadingInvoice) {
return <CustomerInvoiceEditorSkeleton />;
@ -137,6 +166,7 @@ export const InvoiceUpdatePage = () => {
return (
<InvoiceProvider
taxCatalog={taxCatalog}
company_id={invoiceData.company_id}
status={invoiceData.status}
language_code={invoiceData.language_code}
@ -168,27 +198,30 @@ export const InvoiceUpdatePage = () => {
</AppHeader>
<AppContent>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
{
isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)
}
<FormProvider {...form}>
<CustomerInvoiceEditForm
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit}
onError={handleError}
className='max-w-full'
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
</InvoiceProvider>
<FormProvider {...form}>
<CustomerInvoiceEditForm
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit}
onError={handleError}
className='max-w-full'
/>
</FormProvider>
</AppContent >
</UnsavedChangesProvider >
</InvoiceProvider >
);
};
*/

View File

@ -1,9 +1,4 @@
import {
MoneyDTOHelper,
PercentageDTOHelper,
QuantityDTOHelper,
SpainTaxCatalogProvider,
} from "@erp/core";
import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core";
import {
GetCustomerInvoiceByIdResponseDTO,
UpdateCustomerInvoiceByIdRequestDTO,
@ -15,9 +10,8 @@ import { InvoiceFormData } from "./invoice.form.schema";
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const invoiceDtoToFormAdapter = {
fromDto(dto: GetCustomerInvoiceByIdResponseDTO): InvoiceFormData {
const taxCatalog = SpainTaxCatalogProvider();
fromDto(dto: GetCustomerInvoiceByIdResponseDTO, context: InvoiceContextValue): InvoiceFormData {
const { taxCatalog } = context;
return {
invoice_number: dto.invoice_number,
series: dto.series,
@ -68,6 +62,7 @@ export const invoiceDtoToFormAdapter = {
},
toDto(form: InvoiceFormData, context: InvoiceContextValue): UpdateCustomerInvoiceByIdRequestDTO {
const { currency_code } = context;
return {
series: form.series,
@ -87,7 +82,7 @@ export const invoiceDtoToFormAdapter = {
is_non_valued: item.is_non_valued ? "true" : "false",
description: item.description,
quantity: QuantityDTOHelper.fromNumericString(item.quantity, 4),
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, context.currency_code, 4),
unit_amount: MoneyDTOHelper.fromNumericString(item.unit_amount, currency_code, 4),
discount_percentage: PercentageDTOHelper.fromNumericString(item.discount_percentage, 2),
tax_codes: item.tax_codes,
})),

View File

@ -25,6 +25,7 @@
"@types/express": "^4.17.21",
"@types/react": "^19.1.2",
"@types/react-i18next": "^8.1.0",
"@types/react-router-dom": "^5.3.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},

View File

@ -118,7 +118,7 @@ export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEd
variant='ghost'
size='icon'
onClick={() => onOpenChange(false)}
className='h-8 w-8'
className='size-8'
>
<X className='h-4 w-4' />
</Button>

View File

@ -70,9 +70,9 @@ export const CustomerViewPage = () => {
<div className='flex items-start gap-4'>
<div className='flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10'>
{customer?.is_company ? (
<Building2 className='h-8 w-8 text-primary' />
<Building2 className='size-8 text-primary' />
) : (
<User className='h-8 w-8 text-primary' />
<User className='size-8 text-primary' />
)}
</div>
<div>

View File

@ -17,12 +17,9 @@
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@repo/typescript-config": "workspace:*",
"@types/jest": "^29.5.14",
"change-case": "^5.4.4",
"inquirer": "^12.5.2",
"jest": "^29.7.0",
"plop": "^4.0.4",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"turbo": "^2.5.1",
"typescript": "5.8.3"

View File

@ -22,8 +22,9 @@ export const FieldGroup = ({ className, children, ...props }: React.ComponentPro
export const Field = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div
data-slot='field'
className={cn(
"[&>[data-slot=label]+[data-slot=control]]:mt-3 [&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=description]+[data-slot=control]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3 [&>[data-slot=control]+[data-slot=error]]:mt-3 *:data-[slot=label]:font-medium bg-transparent",
"bg-transparent",
className
)}
{...props}

View File

@ -33,7 +33,7 @@ export const FullscreenModal = ({
{/* Header fijo */}
<div className='flex items-center justify-between p-6 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
<h2 className='text-2xl font-semibold tracking-tight'>{title}</h2>
<Button variant='ghost' size='sm' onClick={onClose} className='h-8 w-8 p-0'>
<Button variant='ghost' size='sm' onClick={onClose} className='size-8 p-0'>
<X className='h-4 w-4' />
<span className='sr-only'>Cerrar</span>
</Button>

View File

@ -537,7 +537,7 @@ export function DataTable({
<div className='ml-auto flex items-center gap-2 lg:ml-0'>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
className='hidden size-8 p-0 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>

View File

@ -33,8 +33,6 @@ export function NavMain({
}) {
const navigate = useNavigate();
console.log(window.location.href);
return (
<SidebarGroup>
<SidebarGroupContent className='flex flex-col gap-2'>

View File

@ -45,7 +45,7 @@ export function NavUser({
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<Avatar className='h-8 w-8 rounded-lg grayscale'>
<Avatar className='size-8 rounded-lg grayscale'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>
@ -64,7 +64,7 @@ export function NavUser({
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
<Avatar className='h-8 w-8 rounded-lg'>
<Avatar className='size-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>

File diff suppressed because it is too large Load Diff