From ef8a20d29641b8e41c9e768e0868eb3af42396b4 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 12 Oct 2025 20:36:33 +0200 Subject: [PATCH] Facturas de cliente --- apps/server/package.json | 2 - apps/web/src/app.tsx | 6 +- .../web/hooks/use-hook-form/use-hook-form.ts | 13 +- modules/customer-invoices/package.json | 1 + .../components/customer-invoices-layout.tsx | 2 +- .../editor/customer-invoice-edit-form.tsx | 59 +- .../editor/invoice-basic-info-fields.tsx | 8 +- .../components/editor/invoice-tax-summary.tsx | 6 +- .../editor/items/amount-dto-input.tsx | 160 - ...input-field.tsx => amount-input-field.tsx} | 47 +- .../components/editor/items/amount-input.tsx | 182 + .../web/components/editor/items/item-row.tsx | 35 +- .../editor/items/items-editor-toolbar.tsx | 74 +- .../components/editor/items/items-editor.bak | 173 + .../components/editor/items/items-editor.tsx | 93 +- .../editor/items/percentage-dto-input.tsx | 140 - ...t-field.tsx => percentage-input-field.tsx} | 48 +- .../editor/items/percentage-input.tsx | 218 + .../editor/items/quantity-dto-input.tsx | 231 - ...put-field.tsx => quantity-input-field.tsx} | 36 +- .../editor/items/quantity-input.tsx | 210 + .../src/web/context/invoice-context.tsx | 15 +- .../calculate-invoice-header-amounts.ts | 58 + .../domain/calculate-invoice-item-amounts.ts | 72 + .../customer-invoices/src/web/domain/index.ts | 2 + .../hooks/calcs/use-invoice-auto-recalc.ts | 333 +- .../web/hooks/use-items-table-navigation.ts | 56 +- .../web/pages/update/invoice-update-comp.tsx | 117 + .../web/pages/update/invoice-update-page.tsx | 141 +- .../src/web/schemas/invoice-dto.adapter.ts | 15 +- modules/customers/package.json | 1 + .../pages/update/customer-update-modal.tsx | 2 +- .../src/web/pages/view/customer-view-page.tsx | 4 +- package.json | 3 - .../rdx-ui/src/components/form/fieldset.tsx | 3 +- .../src/components/full-screen-modal.tsx | 2 +- .../src/components/layout/data-table.tsx | 2 +- .../rdx-ui/src/components/layout/nav-main.tsx | 2 - .../rdx-ui/src/components/layout/nav-user.tsx | 4 +- pnpm-lock.yaml | 5978 ++++++----------- 40 files changed, 3699 insertions(+), 4855 deletions(-) delete mode 100644 modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx rename modules/customer-invoices/src/web/components/editor/items/{amount-dto-input-field.tsx => amount-input-field.tsx} (50%) create mode 100644 modules/customer-invoices/src/web/components/editor/items/amount-input.tsx create mode 100644 modules/customer-invoices/src/web/components/editor/items/items-editor.bak delete mode 100644 modules/customer-invoices/src/web/components/editor/items/percentage-dto-input.tsx rename modules/customer-invoices/src/web/components/editor/items/{percentage-dto-input-field.tsx => percentage-input-field.tsx} (50%) create mode 100644 modules/customer-invoices/src/web/components/editor/items/percentage-input.tsx delete mode 100644 modules/customer-invoices/src/web/components/editor/items/quantity-dto-input.tsx rename modules/customer-invoices/src/web/components/editor/items/{quantity-dto-input-field.tsx => quantity-input-field.tsx} (57%) create mode 100644 modules/customer-invoices/src/web/components/editor/items/quantity-input.tsx create mode 100644 modules/customer-invoices/src/web/domain/calculate-invoice-header-amounts.ts create mode 100644 modules/customer-invoices/src/web/domain/calculate-invoice-item-amounts.ts create mode 100644 modules/customer-invoices/src/web/domain/index.ts create mode 100644 modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx diff --git a/apps/server/package.json b/apps/server/package.json index b4aabb1a..335cd68e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -33,8 +33,6 @@ "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/response-time": "^2.3.8", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", "tsconfig-paths": "^4.2.0", "tsup": "8.4.0", "tsx": "4.19.4", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index e07bc6f7..2513578f 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -32,10 +32,10 @@ export const App = () => { const axiosInstance = createAxiosInstance({ baseURL: import.meta.env.VITE_API_SERVER_URL, - getAccessToken, + getAccessToken: () => null, //getAccessToken, onAuthError: () => { - console.error("APP, Error de autenticación"); - clearAccessToken(); + //console.error("APP, Error de autenticación"); + //clearAccessToken(); //window.location.href = "/login"; // o usar navegación programática }, }); diff --git a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts index dab1a878..1fd33a75 100644 --- a/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts +++ b/modules/core/src/web/hooks/use-hook-form/use-hook-form.ts @@ -8,15 +8,13 @@ type UseHookFormProps TContext > & { resolverSchema: z4.$ZodType; - defaultValues: UseFormProps["defaultValues"]; - values: UseFormProps["values"]; + initialValues: UseFormProps["defaultValues"]; onDirtyChange?: (isDirty: boolean) => void; }; export function useHookForm({ resolverSchema, - defaultValues, - values, + initialValues, disabled, onDirtyChange, ...rest @@ -24,8 +22,7 @@ export function useHookForm({ ...rest, resolver: zodResolver(resolverSchema), - defaultValues, - values, + defaultValues: initialValues, disabled, }); @@ -39,12 +36,12 @@ export function useHookForm { const applyReset = async () => { - const values = typeof defaultValues === "function" ? await defaultValues() : defaultValues; + const values = typeof initialValues === "function" ? await initialValues() : initialValues; form.reset(values); }; applyReset(); - }, [defaultValues, form]); + }, [initialValues, form]); return form; } diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index d882451a..415d0c9b 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -29,6 +29,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", "@types/react-i18next": "^8.1.0", + "@types/react-router-dom": "^5.3.3", "typescript": "^5.8.3" }, "dependencies": { diff --git a/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx b/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx index fddb9644..786cc726 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-layout.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren } from "react"; export const CustomerInvoicesLayout = ({ children }: PropsWithChildren) => { - return
{children}
; + return
{children}
; }; 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 6718a466..639234d8 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 @@ -1,13 +1,13 @@ import { FieldErrors, useFormContext } from "react-hook-form"; import { FormDebug } from "@erp/core/components"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@repo/shadcn-ui/components'; -import { useTranslation } from "../../i18n"; +import { cn } from '@repo/shadcn-ui/lib/utils'; import { InvoiceFormData } from "../../schemas"; import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields"; +import { InvoiceItems } from './invoice-items-editor'; import { InvoiceNotes } from './invoice-tax-notes'; -import { InvoiceTaxSummary } from "./invoice-tax-summary"; -import { InvoiceTotals } from "./invoice-totals"; +import { InvoiceTaxSummary } from './invoice-tax-summary'; +import { InvoiceTotals } from './invoice-totals'; import { InvoiceRecipient } from "./recipient"; interface CustomerInvoiceFormProps { @@ -23,46 +23,43 @@ export const CustomerInvoiceEditForm = ({ onError, className, }: CustomerInvoiceFormProps) => { - const { t } = useTranslation(); const form = useFormContext(); + console.log("CustomerInvoiceEditForm") return (
-
+
-
- - - +
+
+ +
- - - - +
+ +
-
- +
+ +
-
-
- {/* */} -
-
- -
- -
- -
- -
- -
+
+
+ + +
+ +
+ +
+ +
+
); diff --git a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx index 985fb448..b4a6d547 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx @@ -25,8 +25,8 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => { {t("form_groups.basic_into.description")} - - + + ) => { /> - + ) => { /> - + ) => { const { t } = useTranslation(); - const { control, getValues } = useFormContext(); + const { control } = useFormContext(); const taxes = useWatch({ control, @@ -16,8 +16,6 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => { defaultValue: [], }); - console.log(getValues()); - const formatCurrency = (amount: number) => { return new Intl.NumberFormat("es-ES", { style: "currency", @@ -62,7 +60,7 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => { {displayTaxes.length === 0 && (
- +

No hay impuestos aplicados

)} 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 deleted file mode 100644 index 57341441..00000000 --- a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { MoneyDTO } from '@erp/core'; -import { useMoney } from '@erp/core/hooks'; -import { cn } from '@repo/shadcn-ui/lib/utils'; -import * as React from "react"; - -type AmountDTOEmptyMode = "blank" | "placeholder" | "value"; -type AmountDTOReadOnlyMode = "textlike-input" | "normal"; - -type AmountDTOInputProps = { - value: MoneyDTO | null | undefined; - onChange: (next: MoneyDTO | null) => void; - readOnly?: boolean; - readOnlyMode?: AmountDTOReadOnlyMode; // "textlike-input" evita foco/edición - id?: string; - "aria-label"?: string; - className?: string; - step?: number; // incremento por flechas (p.ej. 0.01) - emptyMode?: AmountDTOEmptyMode; // representación cuando DTO está vacío - emptyText?: string; - scale?: 0 | 1 | 2 | 3 | 4; // decimales del DTO; por defecto 4 - currencyCode?: string; // si no viene en value (fallback) - locale?: string; // opcional: fuerza locale (sino usa i18n del hook) - currencyFallback?: string; // p.ej. "EUR" si faltase en el DTO -}; - - -export function AmountDTOInput({ - value, - onChange, - readOnly = false, - readOnlyMode = "textlike-input", - id, - "aria-label": ariaLabel = "Amount", - className, - step = 0.01, - emptyMode = "blank", - emptyText = "", - scale = 4, - locale = "es-ES", - currencyFallback = "EUR", -}: AmountDTOInputProps) { - const { - formatCurrency, - parse, - toNumber, - fromNumber, - roundToScale, - isEmptyMoneyDTO, - defaultScale, - } = useMoney({ locale }); - - const [raw, setRaw] = React.useState(""); - 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/amount-dto-input-field.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-input-field.tsx similarity index 50% rename from modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx rename to modules/customer-invoices/src/web/components/editor/items/amount-input-field.tsx index e3fc0dfc..2c8f3bb5 100644 --- a/modules/customer-invoices/src/web/components/editor/items/amount-dto-input-field.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/amount-input-field.tsx @@ -1,44 +1,30 @@ -import { MoneyDTO } from '@erp/core'; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@repo/shadcn-ui/components"; import { Control, FieldPath, FieldValues } from "react-hook-form"; -import { AmountDTOInput } from './amount-dto-input'; +import { AmountInput, AmountInputProps } from './amount-input'; -type AmountDTOInputFieldProps = { + +type AmountInputFieldProps = { + inputId?: string; 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.) -}; +} & Omit -export function AmountDTOInputField({ +export function AmountInputField({ + inputId, control, name, label, description, required = false, - readOnly = false, - inputId, - "aria-label": ariaLabel = "amount", - className, - step = 0.01, - emptyMode = "blank", - emptyText = "", - scale = 4, - locale, -}: AmountDTOInputFieldProps) { + ...inputProps + +}: AmountInputFieldProps) { return ( ({ ) : null} - {description ? {description} : null} diff --git a/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx b/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx new file mode 100644 index 00000000..b6975b4c --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/amount-input.tsx @@ -0,0 +1,182 @@ +import { useMoney } from '@erp/core/hooks'; +import { cn } from '@repo/shadcn-ui/lib/utils'; +import * as React from "react"; +import { InputEmptyMode, InputReadOnlyMode } from './quantity-input'; + + +export type AmountInputProps = { + value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores + onChange: (next: number | "") => void; + readOnly?: boolean; + readOnlyMode?: InputReadOnlyMode; // default "textlike-input" + id?: string; + "aria-label"?: string; + step?: number; // ↑/↓; default 0.01 + emptyMode?: InputEmptyMode; // cómo presentar vacío + emptyText?: string; // texto en vacío para value/placeholder + scale?: number; // decimales; default 2 (ej. 4 para unit_amount) + locale?: string; // p.ej. "es-ES" + currency?: string; // p.ej. "EUR" + className?: string; +}; + +export function AmountInput({ + value, + onChange, + readOnly = false, + readOnlyMode = "textlike-input", + id, + "aria-label": ariaLabel = "Amount", + step = 1.00, + emptyMode = "blank", + emptyText = "", + scale = 2, + locale, + currency = "EUR", + className, +}: AmountInputProps) { + + // Hook de dinero para parseo/redondeo consistente con el resto de la app + const { parse, roundToScale } = useMoney({ locale, fallbackCurrency: currency as any }); + + const [raw, setRaw] = React.useState(""); + const [focused, setFocused] = React.useState(false); + + const formatCurrencyNumber = React.useCallback( + (n: number) => + new Intl.NumberFormat(locale ?? undefined, { + style: "currency", + currency, + maximumFractionDigits: scale, + minimumFractionDigits: Number.isInteger(n) ? 0 : 0, + useGrouping: true, + }).format(n), + [locale, currency, scale] + ); + + // Derivar texto visual desde prop `value` + const visualText = React.useMemo(() => { + if (value === "" || value == null) { + return emptyMode === "value" ? emptyText : ""; + } + const numeric = + typeof value === "number" + ? value + : (parse(String(value)) ?? Number(String(value).replace(/[^\d.,\-]/g, "").replace(/\./g, "").replace(",", "."))); + if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : ""; + const n = roundToScale(numeric, scale); + return formatCurrencyNumber(n); + }, [value, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber]); + + const isShowingEmptyValue = emptyMode === "value" && raw === emptyText; + + // Sin foco → mantener visual + React.useEffect(() => { + if (!focused) setRaw(visualText); + }, [visualText, focused]); + + const handleChange = React.useCallback((e: React.ChangeEvent) => { + setRaw(e.currentTarget.value); + }, []); + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + setFocused(true); + // pasar de visual con símbolo → crudo + if (emptyMode === "value" && e.currentTarget.value === emptyText) { + setRaw(""); + return; + } + const current = + parse(e.currentTarget.value) ?? + (value === "" || value == null ? null : typeof value === "number" ? value : parse(String(value))); + setRaw(current !== null && current !== undefined ? String(current) : ""); + }, + [emptyMode, emptyText, parse, value] + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + setFocused(false); + const txt = e.currentTarget.value.trim(); + if (txt === "" || isShowingEmptyValue) { + onChange(""); + setRaw(emptyMode === "value" ? emptyText : ""); + return; + } + const n = parse(txt); + if (n === null) { + onChange(""); + setRaw(emptyMode === "value" ? emptyText : ""); + return; + } + const rounded = roundToScale(n, scale); + onChange(rounded); + setRaw(formatCurrencyNumber(rounded)); // vuelve a visual con símbolo + }, + [isShowingEmptyValue, onChange, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber] + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (readOnly) return; + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + e.preventDefault(); + const base = parse(isShowingEmptyValue ? "" : raw) ?? 0; + const delta = (e.shiftKey ? 10 : 1) * step * (e.key === "ArrowUp" ? 1 : -1); + const rounded = roundToScale(base + delta, scale); + onChange(rounded); + setRaw(String(rounded)); // crudo durante edición + }, + [readOnly, parse, isShowingEmptyValue, raw, step, roundToScale, scale, onChange] + ); + + const handleBlock = React.useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + }, []); + + if (readOnly && readOnlyMode === "textlike-input") { + return ( + e.preventDefault()} + value={visualText} + className={cn( + "w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none", + "focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default", + className + )} + /> + ); + } + + return ( + + ); +} diff --git a/modules/customer-invoices/src/web/components/editor/items/item-row.tsx b/modules/customer-invoices/src/web/components/editor/items/item-row.tsx index 14d9af79..dbb27ac4 100644 --- a/modules/customer-invoices/src/web/components/editor/items/item-row.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/item-row.tsx @@ -1,17 +1,17 @@ import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components"; +import { cn } from '@repo/shadcn-ui/lib/utils'; import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react"; import { Control, Controller } from "react-hook-form"; import { useTranslation } from '../../../i18n'; -import { InvoiceItemFormData } from '../../../schemas'; import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select'; -import { AmountDTOInputField } from './amount-dto-input-field'; +import { AmountInputField } from './amount-input-field'; import { HoverCardTotalsSummary } from './hover-card-total-summary'; -import { PercentageDTOInputField } from './percentage-dto-input-field'; -import { QuantityDTOInputField } from './quantity-dto-input-field'; +import { PercentageInputField } from './percentage-input-field'; +import { QuantityInputField } from './quantity-input-field'; export type ItemRowProps = { + control: Control, - item: InvoiceItemFormData; rowIndex: number; isSelected: boolean; isFirst: boolean; @@ -26,8 +26,8 @@ export type ItemRowProps = { export const ItemRow = ({ + control, - item, rowIndex, isSelected, isFirst, @@ -71,11 +71,14 @@ export const ItemRow = ({