Facturas de cliente
This commit is contained in:
parent
9fb2955d30
commit
ef8a20d296
@ -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",
|
||||
|
||||
@ -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
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => {
|
||||
return <section>{children}</section>;
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
2
modules/customer-invoices/src/web/domain/index.ts
Normal file
2
modules/customer-invoices/src/web/domain/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./calculate-invoice-header-amounts";
|
||||
export * from "./calculate-invoice-item-amounts";
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 >
|
||||
);
|
||||
};
|
||||
*/
|
||||
@ -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,
|
||||
})),
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -33,8 +33,6 @@ export function NavMain({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
console.log(window.location.href);
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className='flex flex-col gap-2'>
|
||||
|
||||
@ -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>
|
||||
|
||||
5978
pnpm-lock.yaml
5978
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user