From 95e7d85a6f3799e362056e046977b8c99c57f6a4 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 7 Oct 2025 18:38:03 +0200 Subject: [PATCH] Facturas de cliente --- biome.json | 3 + modules/core/src/web/hooks/use-money.ts | 225 ++++++++++++++++-- modules/core/src/web/hooks/use-quantity.ts | 221 ++++++++++++++--- .../src/common/locales/en.json | 5 + .../editor/customer-invoice-edit-form.tsx | 2 +- .../editor/invoice-items-editor.tsx | 57 ++--- .../editor/items/amount-dto-input-field.tsx | 74 ++++++ .../editor/items/amount-dto-input.tsx | 159 +++++++++++++ .../editor/items/hover-card-total-summary.tsx | 35 +-- .../src/web/components/editor/items/index.ts | 1 + .../editor/items/items-editor-row.tsx | 218 +++++++++++++++++ .../editor/items/items-editor-toolbar.tsx | 95 ++++++++ .../components/editor/items/items-editor.tsx | 137 +++++++++++ .../components/editor/items/numeric-input.tsx | 48 ++++ .../items/percentage-dto-input-field.tsx | 77 ++++++ .../editor/items/percentage-dto-input.tsx | 137 +++++++++++ .../editor/items/quantity-dto-input-field.tsx | 67 ++++++ .../editor/items/quantity-dto-input.tsx | 150 ++++++++++++ .../editor/items/tax-multi-select.tsx | 47 ++++ .../web/components/editor/items/types.d.ts | 57 +++++ .../customer-invoices/src/web/hooks/index.ts | 1 + .../web/hooks/use-calculate-item-amounts.ts | 154 ++++++++---- .../use-customer-invoice-item-summary.ts | 107 ++++++--- .../web/hooks/use-items-table-navigation.ts | 112 +++++++++ .../schemas/customer-invoices.form.schema.ts | 47 +++- packages/rdx-ui/src/components/form/index.tsx | 2 + 26 files changed, 2049 insertions(+), 189 deletions(-) create mode 100644 modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/items-editor-row.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/items-editor-toolbar.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/items-editor.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/numeric-input.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/percentage-dto-input-field.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/percentage-dto-input.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/quantity-dto-input-field.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/quantity-dto-input.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/tax-multi-select.tsx create mode 100644 modules/customer-invoices/src/web/hooks/use-items-table-navigation.ts diff --git a/biome.json b/biome.json index 3b48da29..be879bd7 100644 --- a/biome.json +++ b/biome.json @@ -36,6 +36,9 @@ "style": { "useImportType": "off", "noNonNullAssertion": "info" + }, + "a11y": { + "useSemanticElements": "info" } } }, diff --git a/modules/core/src/web/hooks/use-money.ts b/modules/core/src/web/hooks/use-money.ts index a789de1d..924562c1 100644 --- a/modules/core/src/web/hooks/use-money.ts +++ b/modules/core/src/web/hooks/use-money.ts @@ -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, + ] + ); } diff --git a/modules/core/src/web/hooks/use-quantity.ts b/modules/core/src/web/hooks/use-quantity.ts index 4b206ef9..4f167ea9 100644 --- a/modules/core/src/web/hooks/use-quantity.ts +++ b/modules/core/src/web/hooks/use-quantity.ts @@ -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, + ] + ); } diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index c7220f0f..dc16262e 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -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", diff --git a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx index 31e8d5a6..65c57758 100644 --- a/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx +++ b/modules/customer-invoices/src/web/components/editor/customer-invoice-edit-form.tsx @@ -26,7 +26,7 @@ export const CustomerInvoiceEditForm = ({ const form = useFormContext(); return ( -
+
diff --git a/modules/customer-invoices/src/web/components/editor/invoice-items-editor.tsx b/modules/customer-invoices/src/web/components/editor/invoice-items-editor.tsx index 894aec44..0ff54797 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-items-editor.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-items-editor.tsx @@ -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) => { - 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 ( @@ -91,7 +64,9 @@ export const InvoiceItems = () => {
- {viewMode === "blocks" ? ( + + + {/*viewMode === "blocks" ? ( { duplicateItem, removeItem } /> - )} + ) */} ); diff --git a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx new file mode 100644 index 00000000..e3fc0dfc --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx @@ -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 = { + control: Control; + name: FieldPath; + 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({ + control, + name, + label, + description, + required = false, + readOnly = false, + inputId, + "aria-label": ariaLabel = "amount", + className, + step = 0.01, + emptyMode = "blank", + emptyText = "", + scale = 4, + locale, +}: AmountDTOInputFieldProps) { + return ( + ( + + {label ? ( + + {label} {required ? : null} + + ) : null} + + + + {description ? {description} : null} + + + )} + /> + ); +} \ No newline at end of file diff --git a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx new file mode 100644 index 00000000..882ed974 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx @@ -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(""); + const [focused, setFocused] = React.useState(false); + const ref = React.useRef(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 ( + 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 ( + 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)); + } + }} + /> + ); +} \ No newline at end of file diff --git a/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx b/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx index eb4e0629..bdf108a9 100644 --- a/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx @@ -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 = () => (
@@ -28,16 +33,16 @@ export const HoverCardTotalsSummary = ({
{t("components.hover_card_totals_summary.fields.subtotal_amount")}: - {format(summary.subtotal)} + {formatCurrency(summary.subtotal)}
- {Number(item.discount_percentage?.value ?? 0) > 0 && ( + {Number(data.discount_percentage?.value ?? 0) > 0 && (
- {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}%): - -{format(summary.discountAmount)} + -{formatCurrency(summary.discountAmount)}
)} @@ -45,20 +50,20 @@ export const HoverCardTotalsSummary = ({
{t("components.hover_card_totals_summary.fields.taxable_amount")}: - {format(summary.baseAmount)} + {formatCurrency(summary.baseAmount)}
{summary.taxesBreakdown.map((tax) => (
{tax.label}: - {format(tax.amount)} + {formatCurrency(tax.amount)}
))}
{t("components.hover_card_totals_summary.fields.total_amount")}: - {format(summary.total)} + {formatCurrency(summary.total)}
) @@ -68,7 +73,7 @@ export const HoverCardTotalsSummary = ({ {/* Variante móvil */}
- {children} + {children} Desglose del importe @@ -81,7 +86,7 @@ export const HoverCardTotalsSummary = ({ {/* Variante desktop */}
- {children} + {children} diff --git a/modules/customer-invoices/src/web/components/editor/items/index.ts b/modules/customer-invoices/src/web/components/editor/items/index.ts index 9a3399e9..21476aa0 100644 --- a/modules/customer-invoices/src/web/components/editor/items/index.ts +++ b/modules/customer-invoices/src/web/components/editor/items/index.ts @@ -1,2 +1,3 @@ export * from "./blocks-view"; +export * from "./items-editor"; export * from "./table-view"; diff --git a/modules/customer-invoices/src/web/components/editor/items/items-editor-row.tsx b/modules/customer-invoices/src/web/components/editor/items/items-editor-row.tsx new file mode 100644 index 00000000..4e1ee293 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/items-editor-row.tsx @@ -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 ( + + {/* selección */} + +
+ +
+
+ + {/* # */} + + {rowIndex + 1} + + + {/* description */} + + ( +