From 04edf3df6840515cdbd31f5b8a127cc93f8e8b90 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 8 Oct 2025 15:11:26 +0200 Subject: [PATCH] Facturas de cliente --- .../taxes/json-tax-catalog.provider.ts | 34 ++- .../taxes/spain-tax-catalog.provider.ts | 2 +- .../catalogs/taxes/tax-catalog-types.ts | 2 + .../catalogs/taxes/tax-catalog.provider.ts | 9 +- .../src/web/components/form/form-debug.tsx | 70 ++++- .../form/taxes-multi-select-field.tsx | 8 +- .../src/web/components/taxes-multi-select.tsx | 98 ++++-- modules/customer-invoices/package.json | 1 + .../src/api/infrastructure/dependencies.ts | 4 +- .../src/common/locales/en.json | 5 +- .../src/common/locales/es.json | 10 +- .../editor/invoice-items-editor.tsx | 69 +---- .../editor/items/items-editor-row.tsx | 218 -------------- .../editor/items/items-editor-toolbar.tsx | 110 ++++--- .../components/editor/items/items-editor.tsx | 285 ++++++++++++++++-- .../editor/items/tax-multi-select-field.tsx | 262 ++++++++++++++++ .../editor/items/tax-multi-select.tsx | 47 --- .../web/hooks/use-calculate-item-amounts.ts | 6 +- .../use-customer-invoice-item-summary.ts | 4 +- .../update/customer-invoices-update-page.tsx | 12 +- .../schemas/customer-invoices.form.schema.ts | 8 +- .../models/verifactu-record.model.ts | 4 - .../rdx-ui/src/components/multi-select.tsx | 2 +- pnpm-lock.yaml | 3 + 24 files changed, 785 insertions(+), 488 deletions(-) delete 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/tax-multi-select-field.tsx delete mode 100644 modules/customer-invoices/src/web/components/editor/items/tax-multi-select.tsx diff --git a/modules/core/src/common/catalogs/taxes/json-tax-catalog.provider.ts b/modules/core/src/common/catalogs/taxes/json-tax-catalog.provider.ts index 460315cd..21f00834 100644 --- a/modules/core/src/common/catalogs/taxes/json-tax-catalog.provider.ts +++ b/modules/core/src/common/catalogs/taxes/json-tax-catalog.provider.ts @@ -1,27 +1,23 @@ // --- Adaptador que carga el catálogo JSON en memoria e indexa por code --- import { Maybe } from "@repo/rdx-utils"; -import { TaxCatalogType, TaxItemType } from "./tax-catalog-types"; +import { TaxCatalogType, TaxItemType, TaxLookupItems } from "./tax-catalog-types"; import { TaxCatalogProvider } from "./tax-catalog.provider"; -// Si quieres habilitar la carga desde fichero en Node: -// import * as fs from "node:fs"; -// import * as path from "node:path"; - export class JsonTaxCatalogProvider implements TaxCatalogProvider { // Índice por código normalizado - private readonly index: Map; + private readonly catalog: Map; /** * @param catalog Catálogo ya parseado (p.ej. import JSON o fetch) */ constructor(catalog: TaxCatalogType) { - this.index = new Map(); + this.catalog = new Map(); // Normalizamos códigos a minúsculas y sin espacios for (const item of catalog) { const normalized = JsonTaxCatalogProvider.normalizeCode(item.code); // En caso de duplicados, el último gana (o lanza error si prefieres) - this.index.set(normalized, item); + this.catalog.set(normalized, item); } } @@ -31,15 +27,21 @@ export class JsonTaxCatalogProvider implements TaxCatalogProvider { findByCode(code: string): Maybe { const normalized = JsonTaxCatalogProvider.normalizeCode(code); - const found = this.index.get(normalized); + const found = this.catalog.get(normalized); return found ? Maybe.some(found) : Maybe.none(); } - // Opcional: carga desde fichero JSON en Node (descomenta si lo necesitas) - // static fromJsonFile(filePath: string): JsonTaxCatalogProvider { - // const full = path.resolve(filePath); - // const raw = fs.readFileSync(full, "utf-8"); - // const data = JSON.parse(raw) as TaxCatalogDto; - // return new JsonTaxCatalogProvider(data); - // } + getAll(): TaxItemType[] { + return Array.from(this.catalog.values()); + } + + /** Devuelve un objeto indexado por código, compatible con TaxMultiSelectField */ + toOptionLookup(): TaxLookupItems { + return Array.from(this.catalog.values()); + } + + /** Devuelve la lista única de grupos disponibles */ + groups(): string[] { + return Array.from(new Set(Array.from(this.catalog.values()).map((i) => i.group))); + } } diff --git a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.provider.ts b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.provider.ts index 91ad4b37..cb85feb8 100644 --- a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.provider.ts +++ b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.provider.ts @@ -1,4 +1,4 @@ import { JsonTaxCatalogProvider } from "./json-tax-catalog.provider"; import spainTaxCatalog from "./spain-tax-catalog.json"; -export const spainTaxCatalogProvider = new JsonTaxCatalogProvider(spainTaxCatalog); +export const SpainTaxCatalogProvider = () => new JsonTaxCatalogProvider(spainTaxCatalog); diff --git a/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts b/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts index 4d9e815c..d4882787 100644 --- a/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts +++ b/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts @@ -11,3 +11,5 @@ export type TaxItemType = { }; export type TaxCatalogType = TaxItemType[]; + +export type TaxLookupItems = TaxItemType[]; diff --git a/modules/core/src/common/catalogs/taxes/tax-catalog.provider.ts b/modules/core/src/common/catalogs/taxes/tax-catalog.provider.ts index dc19f987..902048e9 100644 --- a/modules/core/src/common/catalogs/taxes/tax-catalog.provider.ts +++ b/modules/core/src/common/catalogs/taxes/tax-catalog.provider.ts @@ -1,7 +1,7 @@ // --- Puerto (interfaz) para resolver tasas desde un catálogo --- import { Maybe } from "@repo/rdx-utils"; // Usa tu implementación real de Maybe -import { TaxItemType } from "./tax-catalog-types"; +import { TaxCatalogType, TaxItemType, TaxLookupItems } from "./tax-catalog-types"; export interface TaxCatalogProvider { /** @@ -9,4 +9,11 @@ export interface TaxCatalogProvider { * Debe considerar normalización del 'code' según las reglas del catálogo. */ findByCode(code: string): Maybe; + + // devuelve el catálogo completo como array + getAll(): TaxCatalogType; + + toOptionLookup(): TaxLookupItems; + + groups(): string[]; //Devuelve una lista con los grupos } diff --git a/modules/core/src/web/components/form/form-debug.tsx b/modules/core/src/web/components/form/form-debug.tsx index a2c8d047..0e9daeaf 100644 --- a/modules/core/src/web/components/form/form-debug.tsx +++ b/modules/core/src/web/components/form/form-debug.tsx @@ -1,30 +1,68 @@ +import { useState } from "react"; import { useFormContext } from "react-hook-form"; +// Renderiza una propiedad recursivamente con expansión +function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: any; newValue: any }) { + const [open, setOpen] = useState(false); + + const isObject = typeof newValue === "object" && newValue !== null; + + if (!isObject) { + return ( +
  • + {label && {label}: } + {String(oldValue)}{" "} + ➝ {String(newValue)} +
  • + ); + } + + return ( +
  • + + {open && ( +
      + {Object.keys(newValue).map((key) => ( + + ))} +
    + )} +
  • + ); +} + export const FormDebug = () => { - const form = useFormContext(); // ✅ mantiene el tipo de T - - const { - watch, - formState: { isDirty, dirtyFields, defaultValues }, - } = form; - + const { watch, formState } = useFormContext(); + const { isDirty, dirtyFields, defaultValues } = formState; const currentValues = watch(); return ( -
    +

    ¿Formulario modificado? {isDirty ? "Sí" : "No"}

    -
    +
    Campos modificados: {Object.keys(dirtyFields).length > 0 ? ( -
      - {Object.keys(dirtyFields).map((campo) => ( -
    • - {campo}:{" "} - {String(defaultValues![campo])}{" "} - ➝ {String(currentValues[campo])} -
    • +
        + {Object.keys(dirtyFields).map((key) => ( + ))}
      ) : ( diff --git a/modules/core/src/web/components/form/taxes-multi-select-field.tsx b/modules/core/src/web/components/form/taxes-multi-select-field.tsx index 08089016..b24213f2 100644 --- a/modules/core/src/web/components/form/taxes-multi-select-field.tsx +++ b/modules/core/src/web/components/form/taxes-multi-select-field.tsx @@ -12,9 +12,9 @@ import { import { cn } from "@repo/shadcn-ui/lib/utils"; import { Control, FieldPath, FieldValues } from "react-hook-form"; import { useTranslation } from "../../i18n.ts"; -import { TaxesMultiSelect } from "../taxes-multi-select.tsx"; +import { TaxesMultiSelect } from '../taxes-multi-select.tsx'; -type TaxesMultiSelectFieldProps = { +export type TaxesMultiSelectFieldProps = { control: Control; name: FieldPath; label?: string; @@ -24,6 +24,7 @@ type TaxesMultiSelectFieldProps = { required?: boolean; readOnly?: boolean; className?: string; + variant?: "default" | "secondary" | "destructive" | "inverted" }; export function TaxesMultiSelectField({ @@ -36,6 +37,7 @@ export function TaxesMultiSelectField({ required = false, readOnly = false, className, + variant = "inverted", }: TaxesMultiSelectFieldProps) { const { t } = useTranslation(); const isDisabled = disabled || readOnly; @@ -59,7 +61,7 @@ export function TaxesMultiSelectField({
    )} - + = [ { label: "IVA 21%", value: "iva_21", group: "IVA" }, { label: "IVA 10%", value: "iva_10", group: "IVA" }, { label: "IVA 7,5%", value: "iva_7_5", group: "IVA" }, @@ -33,38 +41,82 @@ export const TaxesList = [ { label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" }, ]; -interface TaxesMultiSelect { - name: string; - value: string[]; - onChange: (selectedValues: string[]) => void; - [key: string]: any; // Allow other props to be passed +// Índices para lookup +const TAX_GROUP_BY_VALUE = new Map( + TaxesList.map((t) => [t.value, t.group]) +); + +// Util: deduplicar por grupo manteniendo el *último índice* de `arr` +function dedupeByTaxGroup(arr: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (let i = arr.length - 1; i >= 0; i--) { + const v = arr[i]; + const g = TAX_GROUP_BY_VALUE.get(v) ?? `__nogroup:${v}`; + if (!seen.has(g)) { + out.push(v); + seen.add(g); + } + } + out.reverse(); + return out; } -export const TaxesMultiSelect = (props: TaxesMultiSelect) => { - const { value, onChange, ...otherProps } = props; +// Normaliza usando el *delta* para saber cuál fue el último clicado. +// Si se añadió uno, elimina los anteriores de su grupo. +// Si se quitaron, acepta `next` tal cual. Fallback: dedupe por índice. +function normalizeSelection(prev: readonly string[], next: readonly string[]): string[] { + const added = next.filter((v) => !prev.includes(v)); + const removed = prev.filter((v) => !next.includes(v)); + + if (added.length === 1 && removed.length <= 1) { + const clicked = added[0]; + const g = TAX_GROUP_BY_VALUE.get(clicked); + if (!g) return dedupeByTaxGroup(next); + return next.filter((v) => TAX_GROUP_BY_VALUE.get(v) !== g || v === clicked); + } + + // Multi-selección por teclado/ratón o reordenamientos del componente + return dedupeByTaxGroup(next); +} + + +interface TaxesMultiSelectProps { + name: string; + value: string[]; + variant?: "default" | "secondary" | "destructive" | "inverted" + onChange: (selectedValues: string[]) => void; + [key: string]: any; +} + +export const TaxesMultiSelect = (props: TaxesMultiSelectProps) => { + const { variant, value, onChange, ...otherProps } = props; const { t } = useTranslation(); - const handleOnChange = (selectedValues: string[]) => { - onChange(selectedValues); - }; + const msVariant = + variant as "default" | "secondary" | "destructive" | "inverted" | undefined; - const handleValidateOption = (candidateValue: string) => { - const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3))); - if (exists) { - alert(t("components.taxes_multi_select.invalid_tax_selection")); + // IMPORTANTE: usar `value` (controlado). No usar `defaultValue`. + const handleOnValueChange = (nextValues: string[]) => { + const normalized = normalizeSelection(value, nextValues); + // Evitar renders innecesarios si no cambia + if ( + normalized.length === value.length && + normalized.every((v, i) => v === value[i]) + ) { + return; } - return exists === false; + onChange(normalized); }; return ( -
    +
    defaultCustomerInvoiceItemFormData; export const InvoiceItems = () => { - const [viewMode, setViewMode] = useState<"blocks" | "table">("table"); const { t } = useTranslation(); - const form = useFormContext(); - - const calculateItemAmounts = useCalculateItemAmounts(); - - const nav = useItemsTableNavigation(form, { - name: "items", - createEmpty: createEmptyItem, - firstEditableField: "description", - }); - - const { control, setValue, watch } = form; - const items = watch("items") as CustomerInvoiceItemFormData[]; - return ( @@ -33,54 +14,12 @@ export const InvoiceItems = () => {
    - Detalles de la Factura + {t('form_groups.items.title')} -
    -
    - - -
    - -
    - - {/*viewMode === "blocks" ? ( - - ) : ( - - ) */}
    ); 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 deleted file mode 100644 index 4e1ee293..00000000 --- a/modules/customer-invoices/src/web/components/editor/items/items-editor-row.tsx +++ /dev/null @@ -1,218 +0,0 @@ -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 */} - - ( -