Facturas de cliente
This commit is contained in:
parent
f35b7b098a
commit
95e7d85a6f
@ -36,6 +36,9 @@
|
||||
"style": {
|
||||
"useImportType": "off",
|
||||
"noNonNullAssertion": "info"
|
||||
},
|
||||
"a11y": {
|
||||
"useSemanticElements": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./blocks-view";
|
||||
export * from "./items-editor";
|
||||
export * from "./table-view";
|
||||
|
||||
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user