Facturas de cliente

This commit is contained in:
David Arranz 2025-10-07 18:38:03 +02:00
parent f35b7b098a
commit 95e7d85a6f
26 changed files with 2049 additions and 189 deletions

View File

@ -36,6 +36,9 @@
"style": {
"useImportType": "off",
"noNonNullAssertion": "info"
},
"a11y": {
"useSemanticElements": "info"
}
}
},

View File

@ -1,7 +1,6 @@
import type { MoneyDTO } from "@erp/core/common";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Currency } from "dinero.js";
import * as React from "react";
import {
dineroFromDTO,
dtoFromDinero,
@ -10,21 +9,219 @@ import {
percentageDTO,
sumDTO,
} from "../../common/helpers";
import { useTranslation } from "../i18n";
// Hook minimal: conversiones y formateo local
export function useMoney() {
// --- Utils locales (edición texto → número) ---
// Quita símbolos de moneda/letras, conserva dígitos, signo y , .
const stripCurrencySymbols = (s: string) =>
s
.replace(/[^\d.,\-]/g, "")
.replace(/\s+/g, " ")
.trim();
// Heurística robusta: determina decimal por última ocurrencia de , o .
const parseLocaleNumber = (raw: string): number | null => {
if (!raw) return null;
const s = stripCurrencySymbols(raw);
if (!s) return null;
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let normalized = s;
if (lastComma > -1 && lastDot > -1) {
if (lastComma > lastDot) normalized = s.replace(/\./g, "").replace(",", ".");
else normalized = s.replace(/,/g, "");
} else if (lastComma > -1) {
normalized = s.replace(/\s/g, "").replace(",", ".");
} else {
normalized = s.replace(/\s/g, "");
}
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
};
// Redondeo a escala (por defecto 2)
const roundToScale = (n: number, scale = 2) => {
const f = 10 ** scale;
return Math.round(n * f) / f;
};
// DTO vacío (API puede mandar "", "")
const isEmptyMoneyDTO = (m?: MoneyDTO | null) =>
!m || m.value?.trim?.() === "" || m.scale?.trim?.() === "";
// Convierte DTO→número sin instanciar dinero.js (solo lectura)
const toNumberUnsafe = (dto?: MoneyDTO | null, fallbackScale = 2): number => {
if (isEmptyMoneyDTO(dto)) return 0;
const scale = Number(dto!.scale || fallbackScale);
return Number(dto!.value || 0) / 10 ** scale;
};
// Convierte número→DTO (sin costo extra)
const fromNumberUnsafe = (n: number, currency: Currency = "EUR", scale = 2): MoneyDTO => ({
value: String(Math.round(n * 10 ** scale)),
scale: String(scale),
currency_code: currency,
});
// --- Hook ---
export function useMoney(overrides?: {
locale?: string; // e.g. "es-ES" (si no, i18n.language)
fallbackCurrency?: Currency; // por defecto "EUR"
defaultScale?: number; // por defecto 2
}) {
const { i18n } = useTranslation();
const locale = i18n.language || "es-ES";
const locale = overrides?.locale || i18n.language || "es-ES";
const fallbackCurrency: Currency = overrides?.fallbackCurrency ?? "EUR";
const defaultScale = overrides?.defaultScale ?? 2;
const toDinero = (dto: MoneyDTO) => dineroFromDTO(dto);
const fromDinero = dtoFromDinero;
// Conversión básica
const toNumber = React.useCallback(
(dto?: MoneyDTO | null) => toNumberUnsafe(dto, defaultScale),
[defaultScale]
);
const sum = (dtos: MoneyDTO[]) => sumDTO(dtos);
const multiply = (dto: MoneyDTO, k: number) => multiplyDTO(dto, k);
const percentage = (dto: MoneyDTO, p: number) => percentageDTO(dto, p);
const fromNumber = React.useCallback(
(n: number, currency: Currency = fallbackCurrency, scale: number = defaultScale): MoneyDTO =>
fromNumberUnsafe(n, currency, scale),
[fallbackCurrency, defaultScale]
);
const format = (dto: MoneyDTO) => formatDTO(dto, locale);
// Reescala manteniendo magnitud
const withScale = React.useCallback(
(dto: MoneyDTO, scale: number) => {
const curr = toNumber(dto);
return fromNumber(curr, (dto.currency_code as Currency) || fallbackCurrency, scale);
},
[toNumber, fromNumber, fallbackCurrency]
);
// Memo ligero (referencias estables)
return useMemo(() => ({ toDinero, fromDinero, sum, multiply, percentage, format }), [locale]);
// Formateos
const formatCurrency = React.useCallback(
(dto: MoneyDTO, loc?: string) => formatDTO(dto, loc ?? locale),
[locale]
);
const formatPlain = React.useCallback(
(dto: MoneyDTO, loc?: string) => {
const n = toNumber(dto);
const dec = Number(dto?.scale || defaultScale);
return new Intl.NumberFormat(loc ?? locale, {
maximumFractionDigits: dec,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: true,
}).format(n);
},
[locale, toNumber, defaultScale]
);
const parse = React.useCallback((text: string): number | null => parseLocaleNumber(text), []);
// Adaptadores API
const fromApi = React.useCallback(
(m?: MoneyDTO | null): MoneyDTO | null => (m == null || isEmptyMoneyDTO(m) ? null : m),
[]
);
const toApi = React.useCallback(
(m: MoneyDTO | null, currency: Currency = fallbackCurrency): MoneyDTO =>
m ? m : { value: "", scale: "", currency_code: currency },
[fallbackCurrency]
);
// Operaciones (dinero.js via helpers)
const add = React.useCallback(
(a: MoneyDTO, b: MoneyDTO): MoneyDTO => sumDTO([a, b], fallbackCurrency),
[fallbackCurrency]
);
const sub = React.useCallback(
(a: MoneyDTO, b: MoneyDTO): MoneyDTO => sumDTO([a, multiplyDTO(b, -1)], fallbackCurrency),
[fallbackCurrency]
);
const multiply = React.useCallback(
(dto: MoneyDTO, k: number, rounding: Dinero.RoundingMode = "HALF_EVEN") =>
multiplyDTO(dto, k, rounding, fallbackCurrency),
[fallbackCurrency]
);
const percentage = React.useCallback(
(dto: MoneyDTO, p: number, rounding: Dinero.RoundingMode = "HALF_EVEN") =>
percentageDTO(dto, p, rounding, fallbackCurrency),
[fallbackCurrency]
);
// Estado/Comparaciones
const isZero = React.useCallback((dto?: MoneyDTO | null) => toNumber(dto) === 0, [toNumber]);
const sameCurrency = React.useCallback(
(a?: MoneyDTO | null, b?: MoneyDTO | null) =>
(a?.currency_code || fallbackCurrency) === (b?.currency_code || fallbackCurrency),
[fallbackCurrency]
);
// Stepping teclado con redondeo a escala
const stepNumber = React.useCallback(
(base: number, step = 0.01, scale = defaultScale) => roundToScale(base + step, scale),
[defaultScale]
);
return React.useMemo(
() => ({
// Conversión
toNumber,
fromNumber,
withScale,
// Formateo/parseo
formatCurrency,
formatPlain,
parse,
// DTO vacío / adaptadores
isEmptyMoneyDTO,
fromApi,
toApi,
// Operaciones
add,
sub,
multiply,
percentage,
// Estado/ayudas
isZero,
sameCurrency,
stepNumber,
roundToScale,
// Utils UI
stripCurrencySymbols,
// Config efectiva
locale,
fallbackCurrency,
defaultScale,
// Factory Dinero si se necesita en algún punto de bajo nivel:
toDinero: (dto: MoneyDTO) => dineroFromDTO(dto, fallbackCurrency),
fromDinero: dtoFromDinero,
}),
[
toNumber,
fromNumber,
withScale,
formatCurrency,
formatPlain,
parse,
fromApi,
toApi,
add,
sub,
multiply,
percentage,
isZero,
sameCurrency,
stepNumber,
locale,
fallbackCurrency,
defaultScale,
]
);
}

View File

@ -1,39 +1,206 @@
import { useMemo } from "react";
import * as React from "react";
import { QuantityDTO } from "../../common";
/**
* Hook para manipular cantidades escaladas (value+scale).
* Ejemplo: { value:"1500", scale:"2" } 15.00 unidades
*/
export function useQuantity() {
const toNumber = (q?: QuantityDTO | null): number => {
if (!q?.value || !q.scale) return 0;
return Number(q.value) / 10 ** Number(q.scale);
};
export const isEmptyQuantityDTO = (q?: QuantityDTO | null) =>
!q || q.value.trim() === "" || q.scale.trim() === "";
const fromNumber = (num: number, scale = 2): QuantityDTO => ({
value: Math.round(num * 10 ** scale).toString(),
scale: scale.toString(),
});
// Redondeo a escala (por defecto 2)
const roundToScale = (n: number, scale = 2) => {
const f = 10 ** scale;
return Math.round(n * f) / f;
};
const add = (a: QuantityDTO, b: QuantityDTO): QuantityDTO => {
const scale = Math.max(Number(a.scale), Number(b.scale));
const av = Number(a.value) * 10 ** (scale - Number(a.scale));
const bv = Number(b.value) * 10 ** (scale - Number(b.scale));
return { value: (av + bv).toString(), scale: scale.toString() };
};
// Quita caracteres no numéricos salvo signos y separadores
const stripNumberish = (s: string) => s.replace(/[^\d.,\-]/g, "").trim();
const multiply = (a: QuantityDTO, factor: number): QuantityDTO => {
const scale = Number(a.scale);
const val = Math.round(Number(a.value) * factor);
return { value: val.toString(), scale: scale.toString() };
};
// Parse tolerante: “1.234,5” | “1,234.5” | “1234.5” → número JS
const parseLocaleNumber = (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) {
if (lastComma > lastDot) normalized = s.replace(/\./g, "").replace(",", ".");
else normalized = s.replace(/,/g, "");
} else if (lastComma > -1) normalized = s.replace(",", ".");
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
};
const format = (q: QuantityDTO, decimals = 2): string =>
toNumber(q).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
export function useQuantity(overrides?: {
defaultScale?: number; // por defecto 2
min?: number; // clamp opcional (ej. 0)
max?: number; // clamp opcional
}) {
const defaultScale = overrides?.defaultScale ?? 2;
const min = overrides?.min;
const max = overrides?.max;
return useMemo(() => ({ toNumber, fromNumber, add, multiply, format }), []);
// DTO → número (ej. {100, "2"} → 1)
const toNumber = React.useCallback(
(q?: QuantityDTO | null): number => {
if (isEmptyQuantityDTO(q)) return 0;
const scale = Number(q!.scale || defaultScale);
return Number(q!.value || 0) / 10 ** scale;
},
[defaultScale]
);
// número → DTO (manteniendo escala deseada)
const fromNumber = React.useCallback(
(n: number, scale: number = defaultScale): QuantityDTO => ({
value: String(Math.round(n * 10 ** scale)),
scale: String(scale),
}),
[defaultScale]
);
// Reescala manteniendo magnitud
const withScale = React.useCallback(
(q: QuantityDTO, scale: number) => {
const curr = toNumber(q);
return fromNumber(curr, scale);
},
[toNumber, fromNumber]
);
// Formateo sin relleno de ceros (máx. escala)
const formatPlain = React.useCallback(
(q: QuantityDTO) => {
const n = toNumber(q);
const dec = Number(q.scale || defaultScale);
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: dec,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
},
[toNumber, defaultScale]
);
// Parse texto → número (tolerante ,/.)
const parse = React.useCallback((text: string): number | null => parseLocaleNumber(text), []);
// DTO vacío ↔ null (adaptadores)
const fromApi = React.useCallback(
(q?: QuantityDTO | null): QuantityDTO | null => (q && !isEmptyQuantityDTO(q) ? q : null),
[]
);
const toApi = React.useCallback(
(q: QuantityDTO | null): QuantityDTO => (q ? q : { value: "", scale: "" }),
[]
);
// Operaciones aritméticas simples (misma escala resultado)
const add = React.useCallback(
(a: QuantityDTO, b: QuantityDTO): QuantityDTO => {
const scale = Math.max(Number(a.scale || defaultScale), Number(b.scale || defaultScale));
const av = withScale(a, scale);
const bv = withScale(b, scale);
return { value: String(Number(av.value) + Number(bv.value)), scale: String(scale) };
},
[withScale, defaultScale]
);
const sub = React.useCallback(
(a: QuantityDTO, b: QuantityDTO): QuantityDTO => {
const scale = Math.max(Number(a.scale || defaultScale), Number(b.scale || defaultScale));
const av = withScale(a, scale);
const bv = withScale(b, scale);
return { value: String(Number(av.value) - Number(bv.value)), scale: String(scale) };
},
[withScale, defaultScale]
);
const multiply = React.useCallback(
(q: QuantityDTO, k: number, outScale?: number): QuantityDTO => {
const sc = outScale ?? Number(q.scale || defaultScale);
const n = toNumber(q) * k;
return fromNumber(roundToScale(n, sc), sc);
},
[toNumber, fromNumber, defaultScale]
);
// Stepping teclado (p.ej. ArrowUp/Down)
const stepNumber = React.useCallback(
(base: number, step = 1, scale: number = defaultScale) => {
let next = base + step;
if (min !== undefined) next = Math.max(next, min);
if (max !== undefined) next = Math.min(next, max);
return roundToScale(next, scale);
},
[defaultScale, min, max]
);
// Estado/ayudas
const clamp = React.useCallback(
(n: number, s: number = defaultScale) => {
let v = n;
if (min !== undefined) v = Math.max(v, min);
if (max !== undefined) v = Math.min(v, max);
return roundToScale(v, s);
},
[min, max, defaultScale]
);
const isZero = React.useCallback((q?: QuantityDTO | null) => toNumber(q) === 0, [toNumber]);
return React.useMemo(
() => ({
// Conversión
toNumber,
fromNumber,
withScale,
// Formateo/parseo
formatPlain,
parse,
// DTO vacío / adaptadores
isEmptyQuantityDTO,
fromApi,
toApi,
// Operaciones
add,
sub,
multiply,
// Teclado/estado
stepNumber,
roundToScale,
clamp,
isZero,
// Utils UI
stripNumberish,
// Config efectiva
defaultScale,
min,
max,
}),
[
toNumber,
fromNumber,
withScale,
formatPlain,
parse,
add,
sub,
multiply,
stepNumber,
clamp,
isZero,
defaultScale,
min,
max,
]
);
}

View File

@ -3,6 +3,11 @@
"append_empty_row": "Append row",
"append_empty_row_tooltip": "Append a empty row",
"duplicate_row": "Duplicate",
"duplicate_selected_rows": "Duplicate",
"duplicate_selected_rows_tooltip": "Duplicate selected row(s)",
"remove_selected_rows": "Remove",
"remove_selected_rows_tooltip": "Remove selected row(s)",
"insert_row_above": "Insert row above",
"insert_row_below": "Insert row below",
"remove_row": "Remove",

View File

@ -26,7 +26,7 @@ export const CustomerInvoiceEditForm = ({
const form = useFormContext<CustomerInvoiceFormData>();
return (
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={className}>
<div className='w-full'>
<FormDebug />

View File

@ -2,57 +2,30 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-u
import { Grid3X3Icon, Package, PlusIcon, TableIcon } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useCalculateItemAmounts } from '../../hooks';
import { useFormContext } from "react-hook-form";
import { useCalculateItemAmounts, useItemsTableNavigation } from '../../hooks';
import { useTranslation } from "../../i18n";
import { CustomerInvoiceItemFormData } from "../../schemas";
import { BlocksView, TableView } from "./items";
import { CustomerInvoiceItemFormData, defaultCustomerInvoiceItemFormData } from "../../schemas";
import { ItemsEditor } from "./items";
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const InvoiceItems = () => {
const [viewMode, setViewMode] = useState<"blocks" | "table">("table");
const { t } = useTranslation();
const form = useFormContext();
const calculateItemAmounts = useCalculateItemAmounts();
const { control, setValue, watch } = useFormContext();
const { fields, append, remove, insert, move } = useFieldArray({
control,
const nav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { control, setValue, watch } = form;
const items = watch("items") as CustomerInvoiceItemFormData[];
const updateItem = (index: number, patch: Partial<CustomerInvoiceItemFormData>) => {
const updated = { ...items[index], ...patch };
const recalculated = calculateItemAmounts(updated as CustomerInvoiceItemFormData);
setValue(`${"items"}.${index}`, recalculated, { shouldDirty: true });
};
const duplicateItem = (index: number) => {
const copy = structuredClone(items[index]);
insert(index + 1, copy);
};
const removeItem = (index: number) => {
remove(index)
};
const addNewItem = () => {
const newItem: CustomerInvoiceItemFormData = {
isNonValued: "false",
description: "",
quantity: { value: "0", scale: "2" },
unit_amount: { value: "0", scale: "2", currency_code: "EUR" },
discount_percentage: { value: "0", scale: "2" },
discount_amount: { value: "0", scale: "2", currency_code: "EUR" },
taxable_amount: { value: "0", scale: "2", currency_code: "EUR" },
taxes_amount: { value: "0", scale: "2", currency_code: "EUR" },
subtotal_amount: { value: "0", scale: "2", currency_code: "EUR" },
total_amount: { value: "0", scale: "2", currency_code: "EUR" },
tax_codes: ["iva_21"],
};
append(calculateItemAmounts(newItem));
};
return (
<Card className='border-none shadow-none'>
@ -91,7 +64,9 @@ export const InvoiceItems = () => {
</div>
</CardHeader>
<CardContent className='overflow-auto'>
{viewMode === "blocks" ? (
<ItemsEditor />
{/*viewMode === "blocks" ? (
<BlocksView items={items} actions={
addNewItem,
updateItem,
@ -105,7 +80,7 @@ export const InvoiceItems = () => {
duplicateItem,
removeItem
} />
)}
) */}
</CardContent>
</Card>
);

View File

@ -0,0 +1,74 @@
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';
type AmountDTOInputFieldProps<T extends FieldValues> = {
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.)
};
export function AmountDTOInputField<T extends FieldValues>({
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>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<AmountDTOInput
id={inputId}
aria-label={ariaLabel}
value={field.value as MoneyDTO | null}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
scale={scale}
locale={locale}
className={className}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,159 @@
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",
className
)}
placeholder={emptyMode === "placeholder" && isEmptyMoneyDTO(value) ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parse(e.currentTarget.value) ??
(isEmptyMoneyDTO(value) ? null : toNumber(value!));
setRaw(n !== null ? String(n) : "");
}}
onBlur={(e) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange(null);
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, sc);
const dto: MoneyDTO = fromNumber(rounded, cur as any, sc);
onChange(dto);
setRaw(formatCurrency(dto));
}}
onKeyDown={(e) => {
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
const delta = (e.shiftKey ? 10 : 1) * step;
const next = e.key === "ArrowUp" ? base + delta : base - delta;
const rounded = roundToScale(next, sc);
onChange(fromNumber(rounded, cur as any, sc));
setRaw(String(rounded));
}
}}
/>
);
}

View File

@ -1,3 +1,4 @@
import { MoneyDTO, PercentageDTO, QuantityDTO } from '@erp/core';
import { useMoney } from '@erp/core/hooks';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
@ -6,21 +7,25 @@ import {
import { PropsWithChildren } from 'react';
import { useInvoiceItemSummary } from '../../../hooks';
import { useTranslation } from "../../../i18n";
import { CustomerInvoiceItemFormData } from '../../../schemas';
type HoverCardTotalsSummaryProps = PropsWithChildren & {
item: CustomerInvoiceItemFormData
export type HoverCardTotalsSummaryProps = PropsWithChildren & {
data: {
quantity: QuantityDTO | null | undefined;
unit_amount: MoneyDTO | null | undefined;
discount_percentage?: PercentageDTO | null;
tax_codes?: string[] | null;
}
}
export const HoverCardTotalsSummary = ({
item,
children,
data
}: HoverCardTotalsSummaryProps) => {
const { t } = useTranslation();
const { format } = useMoney();
const summary = useInvoiceItemSummary(item);
const { formatCurrency } = useMoney();
const summary = useInvoiceItemSummary(data);
const SummaryBlock = () => (
<div className="space-y-2">
@ -28,16 +33,16 @@ export const HoverCardTotalsSummary = ({
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.subtotal_amount")}:</span>
<span className="font-mono">{format(summary.subtotal)}</span>
<span className="font-mono">{formatCurrency(summary.subtotal)}</span>
</div>
{Number(item.discount_percentage?.value ?? 0) > 0 && (
{Number(data.discount_percentage?.value ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.discount_percentage")} ({item.discount_percentage.value ?? 0}%):
{t("components.hover_card_totals_summary.fields.discount_percentage")} ({data.discount_percentage?.value ?? 0}%):
</span>
<span className="font-mono text-destructive">
-{format(summary.discountAmount)}
-{formatCurrency(summary.discountAmount)}
</span>
</div>
)}
@ -45,20 +50,20 @@ export const HoverCardTotalsSummary = ({
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-muted-foreground">{t("components.hover_card_totals_summary.fields.taxable_amount")}:</span>
<span className="font-mono font-medium">
{format(summary.baseAmount)}
{formatCurrency(summary.baseAmount)}
</span>
</div>
{summary.taxesBreakdown.map((tax) => (
<div key={tax.label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{tax.label}:</span>
<span className="font-mono">{format(tax.amount)}</span>
<span className="font-mono">{formatCurrency(tax.amount)}</span>
</div>
))}
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
<span>{t("components.hover_card_totals_summary.fields.total_amount")}:</span>
<span className="font-mono">{format(summary.total)}</span>
<span className="font-mono">{formatCurrency(summary.total)}</span>
</div>
</div>
)
@ -68,7 +73,7 @@ export const HoverCardTotalsSummary = ({
{/* Variante móvil */}
<div className="md:hidden">
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Desglose del importe</DialogTitle>
@ -81,7 +86,7 @@ export const HoverCardTotalsSummary = ({
{/* Variante desktop */}
<div className="hidden md:block">
<HoverCard>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCardContent className="w-64" align="end">
<SummaryBlock />
</HoverCardContent>

View File

@ -1,2 +1,3 @@
export * from "./blocks-view";
export * from "./items-editor";
export * from "./table-view";

View File

@ -0,0 +1,218 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from '../../../i18n';
import { AmountDTOInputField } from './amount-dto-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { PercentageDTOInputField } from './percentage-dto-input-field';
import { QuantityDTOInputField } from './quantity-dto-input-field';
import { TaxMultiSelect } from './tax-multi-select';
import { TAXES } from './types.d';
export const ItemEditorRow = ({
itemRow,
rowIndex,
locale,
readOnly,
isFirst,
isLast,
checked,
onToggle,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: {
itemRow: Record<"id", string>;
rowIndex: number;
locale: string;
readOnly: boolean;
isFirst: boolean;
isLast: boolean;
checked: boolean;
onToggle: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}) => {
const { t } = useTranslation();
const form = useFormContext();
const { control } = form;
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className="align-top">
<div className="h-5">
<Checkbox
aria-label={t("common.select_row", { n: rowIndex + 1 })}
className="block h-5 w-5 leading-none align-middle"
checked={checked}
onCheckedChange={onToggle}
disabled={readOnly}
/>
</div>
</TableCell>
{/* # */}
<TableCell className="text-left pt-[6px]">
<span className="block translate-y-[-1px] text-muted-foreground">{rowIndex + 1}</span>
</TableCell>
{/* description */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.description`}
render={({ field }) => (
<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"
rows={1}
spellCheck
readOnly={readOnly}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className="text-right">
<QuantityDTOInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
inputId={`quantity-${rowIndex}`}
emptyMode="blank"
/>
</TableCell>
{/* unit */}
<TableCell className="text-right">
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
inputId={`unit-amount-${rowIndex}`}
scale={4}
locale={locale}
/>
</TableCell>
{/* discount */}
<TableCell className="text-right">
<PercentageDTOInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
inputId={`discount-percentage-${rowIndex}`}
showSuffix
/>
</TableCell>
{/* taxes */}
<TableCell>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<TaxMultiSelect
catalog={TAXES}
value={field.value ?? ["iva_21"]}
onChange={field.onChange}
disabled={readOnly}
buttonClassName="h-8 self-start translate-y-[-1px]"
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className="text-right tabular-nums pt-[6px] leading-5">
<HoverCardTotalsSummary data={{ ...itemRow } as any}>
<AmountDTOInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
// @ts-expect-error
readOnlyMode="textlike-input"
locale={locale}
/>
</HoverCardTotalsSummary>
</TableCell>
{/* acciones */}
<TableCell className="pt-[4px]">
<div className="flex justify-end gap-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={readOnly}
aria-label={t("common.duplicate_row")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={readOnly || isFirst}
aria-label={t("common.move_up")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<ArrowUp className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={readOnly || isLast}
aria-label={t("common.move_down")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<ArrowDown className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={readOnly}
aria-label={t("common.remove_row")}
className="h-8 w-8 self-start translate-y-[-1px]"
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow >
);
}

View File

@ -0,0 +1,95 @@
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { CopyPlusIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslation } from '../../../i18n';
export const ItemsEditorToolbar = ({
readOnly,
selectedIdx,
onAdd,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: {
readOnly: boolean;
selectedIdx: number[];
onAdd: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}) => {
const { t } = useTranslation();
const hasSel = selectedIdx.length > 0;
return (
<nav className="flex items-center h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd} disabled={readOnly}>
<PlusIcon className="size-4 mr-1" />
{t("common.add_line")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
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">{t("common.duplicate_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-2" />
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={!hasSel || readOnly}
>
{t("common.move_up")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={!hasSel || readOnly}
>
{t("common.move_down")}
</Button>
<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>
</nav>
);
}

View File

@ -0,0 +1,137 @@
import { Table, TableBody, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import * as React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceItemFormData } from '../../../schemas';
import { ItemEditorRow } from './items-editor-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
interface ItemsEditorProps {
value?: CustomerInvoiceItemFormData[];
onChange?: (items: CustomerInvoiceItemFormData[]) => void;
readOnly?: boolean;
}
export const ItemsEditor = ({ value = [], onChange, readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const form = useFormContext();
const { watch } = form;
const [selection, setSelection] = React.useState<Set<number>>(new Set());
const selectedIdx = React.useMemo(() => [...selection].sort((a, b) => a - b), [selection]);
// Emitir cambios a quien consuma el componente
React.useEffect(() => {
const sub = watch((v) => onChange?.(v.items ?? []));
return () => sub.unsubscribe();
}, [watch, onChange]);
const toggleSel = (i: number) =>
setSelection((prev) => {
const next = new Set(prev);
next.has(i) ? next.delete(i) : next.add(i);
return next;
});
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
readOnly={readOnly}
selectedIdx={selectedIdx}
onAdd={() => nav.addEmpty(true)}
onDuplicate={() => selectedIdx.forEach((i) => nav.duplicate(i))}
onMoveUp={() => selectedIdx.forEach((i) => nav.moveUp(i))}
onMoveDown={() => [...selectedIdx].reverse().forEach((i) => nav.moveDown(i))}
onRemove={() => [...selectedIdx].reverse().forEach((i) => nav.remove(i))}
/>
<div className="container">
<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-[12%]" /> {/* total */}
<col className='w-[10%]' /> {/* actions */}
</colgroup>
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead aria-hidden="true" />
<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'>
{nav.fa.fields.map((f, i) => (
<ItemEditorRow locale={"es"}
key={f.id}
itemRow={f}
rowIndex={i}
readOnly={readOnly}
isFirst={i === 0}
isLast={i === nav.fa.fields.length - 1}
checked={selection.has(i)}
onToggle={() => toggleSel(i)}
onDuplicate={() => nav.duplicate(i)}
onMoveUp={() => nav.moveUp(i)}
onMoveDown={() => nav.moveDown(i)}
onRemove={() => nav.remove(i)}
/>
))}
</TableBody>
<TableFooter>
</TableFooter>
</Table>
</div>
{/* Navegación por TAB: último campo de la fila */}
<LastCellTabHook linesLen={nav.fa.fields.length} onTabFromLast={nav.onTabFromLastCell} />
</div>
);
}
// Navegación por TAB desde el último control de la fila
function LastCellTabHook({
linesLen,
onTabFromLast,
}: {
linesLen: number;
onTabFromLast: (row: number) => void;
}) {
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab" || e.shiftKey) return;
const target = e.target as HTMLElement | null;
if (!target) return;
const tr = target.closest<HTMLTableRowElement>("tr[data-row-index]");
if (!tr) return;
// Asumimos el trigger de impuestos como último focusable de la fila
const isTaxTrigger = target.getAttribute("aria-label")?.toLowerCase().includes("tax");
if (!isTaxTrigger) return;
const rowIndex = Number(tr.dataset.rowIndex ?? -1);
if (rowIndex >= 0) {
e.preventDefault();
onTabFromLast(rowIndex);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [linesLen, onTabFromLast]);
return null;
}

View File

@ -0,0 +1,48 @@
import * as React from "react";
import { parseNum } from './types.d';
type NumericInputProps = React.InputHTMLAttributes<HTMLInputElement> & {
value?: number;
onValueChange: (n: number | undefined) => void;
maxDecimals: 2 | 4;
readOnly?: boolean;
};
export function NumericInput({ value, onValueChange, maxDecimals, readOnly, ...rest }: NumericInputProps) {
const [raw, setRaw] = React.useState<string>(() => value ?? value === 0 ? String(value) : "");
React.useEffect(() => { if (document.activeElement !== ref.current) setRaw(value ?? value === 0 ? String(value) : ""); }, [value]);
const ref = React.useRef<HTMLInputElement>(null);
return (
<input
ref={ref}
data-slot='input'
inputMode="decimal"
pattern="[0-9]*[.,]?[0-9]*"
className="w-full bg-transparent focus:outline-none p-0 text-right tabular-nums font-medium"
aria-live="off"
readOnly={readOnly}
value={raw}
onChange={(e) => setRaw(e.target.value)}
onBlur={() => {
const n = parseNum(raw);
if (n === undefined) { onValueChange(undefined); setRaw(""); return; }
const factor = maxDecimals === 4 ? 1e4 : 1e2;
const rounded = Math.round(n * factor) / factor;
onValueChange(rounded);
// ocultar ceros a la derecha
setRaw(Intl.NumberFormat(undefined, { maximumFractionDigits: maxDecimals }).format(rounded));
}}
onKeyDown={(e) => {
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !readOnly) {
e.preventDefault();
const n = parseNum(raw) ?? 0;
const delta = e.shiftKey ? 1 : 0.1;
const next = e.key === "ArrowUp" ? n + delta : n - delta;
onValueChange(Number(next.toFixed(maxDecimals)));
setRaw(String(Number(next.toFixed(maxDecimals))));
}
}}
{...rest}
/>
);
}

View File

@ -0,0 +1,77 @@
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';
type BaseProps<T extends FieldValues> = {
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;
};
export function PercentageDTOInputField<T extends FieldValues>({
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>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<PercentageDTOInput
id={inputId}
aria-label={ariaLabel}
value={field.value as PercentageDTO | null}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
min={min}
max={max}
showSuffix={showSuffix}
className={className}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,137 @@
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", className)}
readOnly={readOnly}
placeholder={emptyMode === "placeholder" && isEmptyDTO ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); return; }
const n = parse(e.currentTarget.value) ?? (isEmptyDTO ? null : toNumber(value!));
setRaw(n !== null ? String(n) : "");
}}
onBlur={(e) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) { onChange(null); setRaw(emptyMode === "value" ? emptyText : ""); return; }
const n = parse(txt);
if (n === null) { onChange(null); setRaw(emptyMode === "value" ? emptyText : ""); return; }
const rounded = Math.round(clamp2(n) * 1e2) / 1e2;
onChange(fromNumber(rounded, 2));
const plain = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, minimumFractionDigits: Number.isInteger(rounded) ? 0 : 0, useGrouping: false }).format(rounded);
setRaw(showSuffix ? `${plain}%` : plain);
}}
onKeyDown={(e) => {
if (!readOnly && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
const base = parse(isShowingEmptyValue ? "" : raw) ?? 0;
const inc = (e.shiftKey ? 10 : 1) * step;
const next = e.key === "ArrowUp" ? base + inc : base - inc;
const rounded = Math.round(clamp2(next) * 1e2) / 1e2;
onChange(fromNumber(rounded, 2));
setRaw(String(rounded)); // crudo durante edición
}
}}
/>
);
}

View File

@ -0,0 +1,67 @@
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';
type QuantityDTOInputFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
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;
}
export function QuantityDTOInputField<TFormValues extends FieldValues>({
control,
name,
label,
description,
required = false,
readOnly = false,
step = 1,
inputId,
"aria-label": ariaLabel = "quantity",
emptyMode = "blank",
emptyText = "",
}: QuantityDTOInputFieldProps<TFormValues>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<QuantityDTOInput
id={inputId}
aria-label={ariaLabel}
value={field.value as QuantityDTO | null}
onChange={field.onChange}
readOnly={readOnly}
step={step}
emptyMode={emptyMode}
emptyText={emptyText}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,150 @@
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]);
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 leading-5 focus:bg-background", className)}
placeholder={emptyMode === "placeholder" && isEmptyQuantityDTO(value) ? emptyText : undefined}
value={raw}
onChange={(e) => setRaw(e.currentTarget.value)}
onFocus={(e) => {
setFocused(true);
// Pasar de visual (con posible sufijo) → crudo editable
if (emptyMode === "value" && e.currentTarget.value === emptyText) { setRaw(""); return; }
const n = parse(e.currentTarget.value) ?? (isEmptyQuantityDTO(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);
onChange(fromNumber(rounded, sc));
// Visual con posible sufijo
const numTxt = new Intl.NumberFormat(locale, {
maximumFractionDigits: sc,
minimumFractionDigits: Number.isInteger(rounded) ? 0 : 0,
useGrouping: false,
}).format(rounded);
const suf = suffixFor(rounded);
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
}}
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 * (e.key === "ArrowUp" ? 1 : -1);
const rounded = roundToScale(base + delta, sc);
onChange(fromNumber(rounded, sc));
setRaw(String(rounded)); // crudo mientras edita
}
}}
/>
);
}

View File

@ -0,0 +1,47 @@
import { Badge, Button, Command, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, ScrollArea } from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { Check } from "lucide-react";
import * as React from "react";
import { TaxCatalog } from "./types.d";
export function TaxMultiSelect({
catalog, value, onChange, disabled, buttonClassName,
}: { catalog: TaxCatalog; value: string[]; onChange: (ids: string[]) => void; disabled?: boolean; buttonClassName?: string }) {
const [open, setOpen] = React.useState(false);
const selected = value.map(id => catalog[id]).filter(Boolean);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button size="sm" variant="outline" className={cn("justify-start w-full overflow-hidden h-8 self-start", buttonClassName)} disabled={disabled} aria-label="Select taxes">
<div className="flex gap-1 flex-wrap">
{selected.length ? selected.map(t => <Badge key={t.id} variant="secondary">{t.label}</Badge>) : <span className="text-muted-foreground">IVA 21 % por defecto</span>}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start">
<Command>
<CommandInput placeholder="Buscar impuesto…" />
<ScrollArea className="max-h-60">
<CommandList>
{Object.values(catalog).map(t => {
const active = value.includes(t.id);
return (
<CommandItem
key={t.id}
onSelect={() =>
onChange(active ? value.filter(v => v !== t.id) : [...value, t.id])
}
aria-selected={active} role="option"
>
<Check className={`mr-2 h-4 w-4 ${active ? "opacity-100" : "opacity-0"}`} />
{t.label}
</CommandItem>
);
})}
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -4,3 +4,60 @@ export interface CustomItemViewProps {
updateItem?: (index: number, field: string, value: any) => void;
removeItem?: (index: number) => void;
}
// types.ts
export interface InvoiceLine {
description: string;
quantity?: number; // vacío permitido
unit_amount?: number; // vacío permitido (€)
discount_percentage?: number; // 0..100
tax_codes: string[]; // ids de catálogo
}
export interface LineComputed {
subtotal_amount: number; // 4 decimales
taxable_amount: number;
taxes_amount: number;
total_amount: number;
}
export interface TaxItem {
id: string;
label: string;
rate: number;
} // rate en %
export type TaxCatalog = Record<string, TaxItem>;
// taxes-catalog.ts (ejemplo mínimo, amplía según necesidad)
export const TAXES: TaxCatalog = {
iva21: { id: "iva21", label: "IVA 21 %", rate: 21 },
iva10: { id: "iva10", label: "IVA 10 %", rate: 10 },
iva4: { id: "iva4", label: "IVA 4 %", rate: 4 },
re52: { id: "re52", label: "RE 5,2 %", rate: 5.2 },
irpf15: { id: "irpf15", label: "IRPF 15 %", rate: -15 },
};
// util-decimal.ts
export const round4 = (n: number) => Math.round(n * 1e4) / 1e4;
export const parseNum = (v: string) => (v.trim() === "" ? undefined : Number(v.replace(",", ".")));
export const fmtNum = (n: number | undefined, max = 2) =>
n === undefined ? "" : Intl.NumberFormat(undefined, { maximumFractionDigits: max }).format(n);
export const fmtMoney4 = (n: number | undefined) =>
n === undefined ? "" : Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(n);
// calc.ts
import { InvoiceLine, LineComputed, TaxCatalog } from "./types";
export function computeLine(line: InvoiceLine, taxes: TaxCatalog): LineComputed {
const q = line.quantity ?? 0;
const u = line.unit_amount ?? 0;
const subtotal = round4(q * u);
const disc = Math.min(Math.max(line.discount_percentage ?? 0, 0), 100);
const taxable = round4(subtotal * (1 - disc / 100));
const taxesSum = round4(
(line.tax_codes ?? []).reduce((acc, id) => acc + taxable * ((taxes[id]?.rate ?? 0) / 100), 0)
);
const total = round4(taxable + taxesSum);
return {
subtotal_amount: subtotal,
taxable_amount: taxable,
taxes_amount: taxesSum,
total_amount: total,
};
}

View File

@ -5,4 +5,5 @@ export * from "./use-customer-invoice-query";
export * from "./use-customer-invoices-context";
export * from "./use-customer-invoices-query";
export * from "./use-detail-columns";
export * from "./use-items-table-navigation";
export * from "./use-update-customer-invoice-mutation";

View File

@ -1,59 +1,127 @@
import { spainTaxCatalogProvider } from "@erp/core";
import { MoneyDTO, PercentageDTO, TaxCatalogProvider } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { CustomerInvoiceItemFormData } from "../schemas";
import * as React from "react";
import { CustomerInvoiceItem } from "../schemas";
/**
* Recalcula todos los importes de una línea usando los hooks de escala.
*/
export function useCalculateItemAmounts() {
const { multiply, percentage: moneyPct, sum } = useMoney();
type UseCalculateItemAmountsOptions = {
locale: string;
currencyCode: string;
keepNullWhenEmpty?: boolean; // Mantener todos los importes a null cuando la línea está “vacía” (qty+unit vacíos)
taxCatalog: TaxCatalogProvider; // Catálogo de impuestos (inyectable para test)
};
export function useCalculateItemAmounts(opts: UseCalculateItemAmountsOptions) {
const { locale, currencyCode, taxCatalog, keepNullWhenEmpty } = opts;
const {
add,
sub,
multiply,
percentage: moneyPct,
fromNumber,
toNumber,
isEmptyMoneyDTO,
fallbackCurrency,
} = useMoney();
const { toNumber: qtyToNumber } = useQuantity();
const { toNumber: pctToNumber } = usePercentage();
const taxCatalog = useMemo(() => spainTaxCatalogProvider, []);
// Crea un MoneyDTO "cero" con la misma divisa/escala que unit_amount
const zeroOf = React.useCallback(
(unit?: MoneyDTO | null): MoneyDTO => {
const cur = unit?.currency_code ?? fallbackCurrency;
const sc = Number(unit?.scale ?? 2);
return fromNumber(0, cur as any, sc);
},
[fromNumber, fallbackCurrency]
);
return (item: CustomerInvoiceItemFormData): CustomerInvoiceItemFormData => {
const qty = qtyToNumber(item.quantity);
const subtotal = multiply(item.unit_amount, qty);
const discountPct = pctToNumber(item.discount_percentage);
const discountAmount =
discountPct > 0 ? moneyPct(subtotal, discountPct) : { ...subtotal, value: "0" };
const emptyAmountDTO = React.useMemo(
() => ({
value: "",
scale: "4",
currency_code: currencyCode,
}),
[]
);
const base = sum([
subtotal,
{ ...discountAmount, value: (-Number(discountAmount.value)).toString() },
]);
// Impuestos (cada uno es porcentaje sobre la base)
const taxesBreakdown =
item.tax_codes?.map((tax_code) => {
const maybeTax = taxCatalog.findByCode(tax_code);
if (maybeTax.isNone()) {
throw Error(`Código de impuesto no encontrado en el catálogo: "${tax_code}"`);
}
const tax = maybeTax.unwrap()!;
const percentage = pctToNumber({ value: tax.value, scale: tax.scale });
return React.useCallback(
(item: CustomerInvoiceItem): CustomerInvoiceItem => {
const qty = qtyToNumber(item.quantity); // 0 si vacío
const unit = item.unit_amount && !isEmptyMoneyDTO(item.unit_amount) ? item.unit_amount : null;
const zero = zeroOf(unit ?? undefined);
// Línea “vacía”: mantener null si se pide y no hay datos
const isEmptyLine = qty === 0 && (!unit || toNumber(unit) === 0);
if (isEmptyLine && keepNullWhenEmpty) {
return {
label: tax.name,
percentage,
amount: moneyPct(base, percentage),
...item,
subtotal_amount: emptyAmountDTO,
discount_amount: emptyAmountDTO,
taxable_amount: emptyAmountDTO,
taxes_amount: emptyAmountDTO,
total_amount: emptyAmountDTO,
};
}) ?? [];
}
const taxes = sum(taxesBreakdown.map((t) => t.amount));
const total = sum([base, taxes]);
// 1) Subtotal = qty × unit
const subtotal = unit ? multiply(unit, qty) : zero;
return {
...item,
subtotal_amount: subtotal,
discount_amount: discountAmount,
taxable_amount: base,
taxes_amount: taxes,
total_amount: total,
};
};
// 2) Descuento = subtotal × (discount_percentage / 100)
const pctDTO = item.discount_percentage ?? ({ value: "", scale: "" } as PercentageDTO);
const pct = pctToNumber(pctDTO); // 0 si vacío
const discountAmount = pct !== 0 ? moneyPct(subtotal, Math.abs(pct)) : zero;
// 3) Base imponible = subtotal - descuento
const baseAmount = sub(subtotal, discountAmount);
// 4) Impuestos
const taxesBreakdown =
(item.tax_codes
?.map((code) => {
const maybe = taxCatalog.findByCode(code);
if (maybe.isNone()) {
console.warn(`[useCalculateItemAmounts] Tax code not found: "${code}"`);
return null;
}
const tax = maybe.unwrap()!; // { name, value, scale }
const p = pctToNumber({ value: tax.value, scale: tax.scale }); // ej. 21
return moneyPct(baseAmount, p);
})
.filter(Boolean) as MoneyDTO[]) ?? [];
const taxesTotal = taxesBreakdown.length
? taxesBreakdown.reduce((acc, m) => add(acc, m), zero)
: zero;
// 5) Total = base + impuestos
const total = add(baseAmount, taxesTotal);
return {
...item,
subtotal_amount: subtotal,
discount_amount: discountAmount,
taxable_amount: baseAmount,
taxes_amount: taxesTotal,
total_amount: total,
};
},
[
qtyToNumber,
pctToNumber,
multiply,
moneyPct,
add,
sub,
isEmptyMoneyDTO,
zeroOf,
toNumber,
keepNullWhenEmpty,
taxCatalog,
]
);
}

View File

@ -1,73 +1,102 @@
import { MoneyDTO, spainTaxCatalogProvider } from "@erp/core";
import { MoneyDTO, PercentageDTO, QuantityDTO, spainTaxCatalogProvider } from "@erp/core";
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
import { useMemo } from "react";
import { CustomerInvoiceItem, CustomerInvoiceItemFormData } from "../schemas";
/**
* Calcula subtotal, descuento, base imponible, impuestos y total de una línea de factura.
* Trabaja con DTOs escalados (value+scale) como los del backend.
*/
export function useInvoiceItemSummary(item: CustomerInvoiceItem | CustomerInvoiceItemFormData) {
const { multiply, percentage: moneyPercentage, sum } = useMoney();
type ItemShape = {
quantity: QuantityDTO | null | undefined;
unit_amount: MoneyDTO | null | undefined;
discount_percentage?: PercentageDTO | null;
tax_codes?: string[] | null;
};
// ⚠️ Devuelve todo en MoneyDTO manteniendo moneda/escala del unit_amount
export function useInvoiceItemSummary(item: ItemShape) {
const {
add,
sub,
multiply,
percentage: moneyPct,
fromNumber,
isEmptyMoneyDTO,
fallbackCurrency,
} = useMoney();
const { toNumber: qtyToNumber } = useQuantity();
const { toNumber: pctToNumber } = usePercentage();
// Cero monetario con la misma divisa/escala del unit_amount (fallback EUR/2)
const zero = useMemo<MoneyDTO>(() => {
const cur = item.unit_amount?.currency_code ?? fallbackCurrency;
const sc = Number(item.unit_amount?.scale ?? 2);
return fromNumber(0, cur as any, sc);
}, [item.unit_amount?.currency_code, item.unit_amount?.scale, fromNumber, fallbackCurrency]);
const taxCatalog = useMemo(() => spainTaxCatalogProvider, []);
return useMemo(() => {
// 🔹 Cantidad decimal (ej. "100" con scale "2" → 1.00)
const qty = qtyToNumber(item.quantity);
// 1) Cantidad
const qty = qtyToNumber(item.quantity); // 0 si null/DTO vacío
// 🔹 Subtotal = cantidad × precio unitario
const subtotal = multiply(item.unit_amount, qty);
// 2) Subtotal = quantity × unit_amount
const unit = item.unit_amount && !isEmptyMoneyDTO(item.unit_amount) ? item.unit_amount : zero;
const subtotal = multiply(unit, qty); // usa dinero.js, respeta escala
// 🔹 Descuento = subtotal × (discount_percentage / 100)
const discountPct = item.discount_percentage
? item.discount_percentage
: { value: "0", scale: "2" }; // fallback DTO
// 3) Descuento
const pctDTO = item.discount_percentage ?? ({ value: "", scale: "" } as PercentageDTO);
const pct = pctToNumber(pctDTO); // 0 si vacío
const discountAmount = pct !== 0 ? moneyPct(subtotal, pct) : zero;
const discountAmount =
pctToNumber(discountPct) > 0
? moneyPercentage(subtotal, pctToNumber(discountPct))
: ({ ...subtotal, value: "0" } as MoneyDTO);
// 4) Base imponible = subtotal - descuento
const baseAmount = sub(subtotal, discountAmount);
// 🔹 Base imponible = subtotal - descuento
const baseAmount = sum([
subtotal,
{ ...discountAmount, value: (-Number(discountAmount.value)).toString() },
]);
// 🔹 Impuestos (cada uno es porcentaje sobre base)
// 5) Impuestos (cada código es un % sobre base; soporta negativos)
const taxesBreakdown =
item.tax_codes?.map((tax_code) => {
const maybeTax = taxCatalog.findByCode(tax_code);
if (maybeTax.isNone()) {
throw Error(`Código de impuesto no encontrado en el catálogo: "${tax_code}"`);
}
const tax = maybeTax.unwrap()!;
const percentage = pctToNumber({ value: tax.value, scale: tax.scale });
item.tax_codes?.map((code) => {
const maybe = taxCatalog.findByCode(code);
if (maybe.isNone()) return { label: code, percentage: 0, amount: zero };
const tax = maybe.unwrap()!; // { name, value, scale }
const p = pctToNumber({ value: tax.value, scale: tax.scale }); // ej. 21 → 21%
return {
label: tax.name,
percentage,
amount: moneyPercentage(baseAmount, percentage),
percentage: p,
amount: moneyPct(baseAmount, p),
};
}) ?? [];
const taxesTotal = sum(taxesBreakdown.map((t) => t.amount));
// 6) Total impuestos = suma amounts
const taxesTotal = taxesBreakdown.reduce((acc, t) => add(acc, t.amount), zero);
// 🔹 Total = base + impuestos
const total = sum([baseAmount, taxesTotal]);
// 7) Total línea = base + impuestos
const total = add(baseAmount, taxesTotal);
return {
qty,
subtotal,
discountAmount,
baseAmount,
taxesBreakdown,
taxesBreakdown, // [{label, percentage, amount}]
taxesTotal,
total,
};
}, [item, multiply, moneyPercentage, sum, qtyToNumber, pctToNumber, taxCatalog.findByCode]);
}, [
item.quantity,
item.unit_amount,
item.discount_percentage,
item.tax_codes,
qtyToNumber,
pctToNumber,
add,
sub,
multiply,
moneyPct,
isEmptyMoneyDTO,
taxCatalog,
zero,
]);
}

View File

@ -0,0 +1,112 @@
import * as React from "react";
import { FieldValues, UseFormReturn, useFieldArray } from "react-hook-form";
type UseItemsTableNavigationOptions = {
name: string; // Ruta del array, p.ej. "items"
createEmpty: () => Record<string, unknown>;
firstEditableField?: string; // Primer campo editable para enfocar al crear/ir a la fila siguiente (p.ej. "description")
};
export function useItemsTableNavigation(
form: UseFormReturn<FieldValues>,
{ name, createEmpty, firstEditableField = "description" }: UseItemsTableNavigationOptions
) {
const { control, getValues, setFocus } = form;
const fa = useFieldArray({ control, name });
const length = React.useCallback(() => {
const arr = getValues(name) as unknown[];
return Array.isArray(arr) ? arr.length : 0;
}, [getValues, name]);
const focusRowFirstField = React.useCallback(
(rowIndex: number) => {
queueMicrotask(() => {
setFocus(`${name}.${rowIndex}.${firstEditableField}` as any, { shouldSelect: true });
});
},
[name, firstEditableField, setFocus]
);
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);
},
[fa, createEmpty]
);
const duplicate = React.useCallback(
(i: number) => {
const curr = getValues(`${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);
},
[fa, getValues, name]
);
const remove = React.useCallback(
(i: number) => {
if (i < 0 || i >= length()) return;
fa.remove(i);
},
[fa, length]
);
const moveUp = React.useCallback(
(i: number) => {
if (i <= 0) return;
fa.move(i, i - 1);
},
[fa]
);
const moveDown = React.useCallback(
(i: number) => {
const len = length();
if (i < 0 || i >= len - 1) return;
fa.move(i, i + 1);
},
[fa, length]
);
const onTabFromLastCell = React.useCallback(
(rowIndex: number) => {
const len = length();
if (rowIndex === len - 1) {
addEmpty(true);
focusRowFirstField(len);
} else {
focusRowFirstField(rowIndex + 1);
}
},
[length, addEmpty, focusRowFirstField]
);
const onShiftTabFromFirstCell = React.useCallback(
(rowIndex: number) => {
if (rowIndex <= 0) return;
focusRowFirstField(rowIndex - 1);
},
[focusRowFirstField]
);
return {
fa, // { fields, append, remove, insert, move, ... }
addEmpty,
duplicate,
remove,
moveUp,
moveDown,
onTabFromLastCell,
onShiftTabFromFirstCell,
focusRowFirstField,
};
}

View File

@ -4,9 +4,14 @@ import { z } from "zod/v4";
export const CustomerInvoiceItemFormSchema = z.object({
isNonValued: z.boolean().optional(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
description: z.string().optional(),
quantity: QuantitySchema.optional(),
unit_amount: MoneySchema.optional(),
subtotal_amount: MoneySchema.optional(),
discount_percentage: PercentageSchema.optional(),
discount_amount: MoneySchema.optional(),
taxable_amount: MoneySchema.optional(),
tax_codes: z.array(z.string()).default([]),
taxes: z
@ -19,12 +24,8 @@ export const CustomerInvoiceItemFormSchema = z.object({
)
.optional(),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
taxes_amount: MoneySchema.optional(),
total_amount: MoneySchema.optional(),
});
export const CustomerInvoiceFormSchema = z.object({
@ -80,6 +81,34 @@ export const CustomerInvoiceFormSchema = z.object({
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
export const defaultCustomerInvoiceItemFormData: CustomerInvoiceItemFormData = {
description: "",
quantity: {
value: "0",
scale: "2",
},
unit_amount: {
currency_code: "EUR",
value: "0",
scale: "4",
},
discount_percentage: {
value: "0",
scale: "2",
},
tax_codes: ["iva_21"],
total_amount: {
currency_code: "EUR",
value: "0",
scale: "4",
},
};
export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
invoice_number: "",
status: "draft",

View File

@ -5,3 +5,5 @@ export * from "./multi-select-field.tsx";
export * from "./SelectField.tsx";
export * from "./TextAreaField.tsx";
export * from "./TextField.tsx";
export type * from "./types.d.ts";