diff --git a/modules/core/package.json b/modules/core/package.json index f4f4f855..3e8c8817 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -20,12 +20,12 @@ "typescript": "^5.8.3" }, "dependencies": { + "@hookform/resolvers": "^5.0.1", "@repo/rdx-criteria": "workspace:*", "@repo/rdx-ddd": "workspace:*", - "@repo/rdx-utils": "workspace:*", "@repo/rdx-ui": "workspace:*", + "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", - "@hookform/resolvers": "^5.0.1", "@tanstack/react-query": "^5.75.4", "ag-grid-community": "^33.3.0", "axios": "^1.9.0", diff --git a/modules/core/src/api/domain/value-objects/tax.ts b/modules/core/src/api/domain/value-objects/tax.ts index 1c3486a0..bc17f307 100644 --- a/modules/core/src/api/domain/value-objects/tax.ts +++ b/modules/core/src/api/domain/value-objects/tax.ts @@ -91,8 +91,8 @@ export class Tax extends ValueObject { const item = maybeItem.unwrap()!; // Delegamos en create para reusar validación y límites return Tax.create({ - value: item.value, - scale: item.scale ?? Tax.DEFAULT_SCALE, + value: Number(item.value), + scale: Number(item.scale) ?? Tax.DEFAULT_SCALE, name: item.name, code: item.code, // guardamos el code tal cual viene del catálogo }); diff --git a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json index da480483..8c501fb7 100644 --- a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json +++ b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json @@ -2,8 +2,8 @@ { "name": "IVA 21%", "code": "iva_21", - "value": 2100, - "scale": 2, + "value": "2100", + "scale": "2", "group": "IVA", "description": "IVA general. Tipo estándar nacional.", "aeat_code": "01" @@ -11,8 +11,8 @@ { "name": "IVA 18%", "code": "iva_18", - "value": 1800, - "scale": 2, + "value": "1800", + "scale": "2", "group": "IVA", "description": "IVA general. Tipo estándar nacional hasta finales de 2011", "aeat_code": null @@ -20,8 +20,8 @@ { "name": "IVA 16%", "code": "iva_16", - "value": 1600, - "scale": 2, + "value": "1600", + "scale": "2", "group": "IVA", "description": "IVA general. Tipo estándar nacional hasta finales de 2009.", "aeat_code": null @@ -29,8 +29,8 @@ { "name": "IVA 10%", "code": "iva_10", - "value": 1000, - "scale": 2, + "value": "1000", + "scale": "2", "group": "IVA", "description": "IVA reducido para bienes y servicios específicos.", "aeat_code": "03" @@ -38,8 +38,8 @@ { "name": "IVA 7,5%", "code": "iva_7_5", - "value": 750, - "scale": 2, + "value": "750", + "scale": "2", "group": "IVA", "description": "Tipo especial de IVA.", "aeat_code": null @@ -47,8 +47,8 @@ { "name": "IVA 5%", "code": "iva_5", - "value": 500, - "scale": 2, + "value": "500", + "scale": "2", "group": "IVA", "description": "Tipo reducido especial.", "aeat_code": null @@ -56,8 +56,8 @@ { "name": "IVA 4%", "code": "iva_4", - "value": 400, - "scale": 2, + "value": "400", + "scale": "2", "group": "IVA", "description": "IVA superreducido para bienes de primera necesidad.", "aeat_code": "02" @@ -65,8 +65,8 @@ { "name": "IVA 2%", "code": "iva_2", - "value": 200, - "scale": 2, + "value": "200", + "scale": "2", "group": "IVA", "description": "Tipo especial de IVA.", "aeat_code": null @@ -74,8 +74,8 @@ { "name": "IVA 0%", "code": "iva_0", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Operaciones sujetas pero tipo cero.", "aeat_code": "05" @@ -83,8 +83,8 @@ { "name": "Exenta", "code": "iva_exenta", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Operación exenta de IVA.", "aeat_code": "04" @@ -92,8 +92,8 @@ { "name": "No sujeto", "code": "iva_no_sujeto", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Operación no sujeta a IVA.", "aeat_code": "06" @@ -101,8 +101,8 @@ { "name": "IVA Intracomunitario Bienes", "code": "iva_intracomunitario_bienes", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Entrega intracomunitaria de bienes, exenta de IVA.", "aeat_code": "E5" @@ -110,8 +110,8 @@ { "name": "IVA Intracomunitario Servicio", "code": "iva_intracomunitario_servicio", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Prestación intracomunitaria de servicios, exenta.", "aeat_code": "E6" @@ -119,8 +119,8 @@ { "name": "Exportación", "code": "iva_exportacion", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Exportaciones exentas de IVA.", "aeat_code": "E2" @@ -128,8 +128,8 @@ { "name": "Inv. Suj. Pasivo", "code": "iva_inversion_sujeto_pasivo", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IVA", "description": "Inversión del sujeto pasivo.", "aeat_code": "09" @@ -138,8 +138,8 @@ { "name": "Retención 35%", "code": "retencion_35", - "value": 3500, - "scale": 2, + "value": "3500", + "scale": "2", "group": "Retención", "description": "Retención profesional o fiscal tipo máximo.", "aeat_code": null @@ -147,8 +147,8 @@ { "name": "Retención 19%", "code": "retencion_19", - "value": 1900, - "scale": 2, + "value": "1900", + "scale": "2", "group": "Retención", "description": "Retención IRPF general.", "aeat_code": "R1" @@ -156,8 +156,8 @@ { "name": "Retención 15%", "code": "retencion_15", - "value": 1500, - "scale": 2, + "value": "1500", + "scale": "2", "group": "Retención", "description": "Retención para autónomos y profesionales.", "aeat_code": "R2" @@ -165,8 +165,8 @@ { "name": "Retención 7%", "code": "retencion_7", - "value": 700, - "scale": 2, + "value": "700", + "scale": "2", "group": "Retención", "description": "Retención para nuevos autónomos.", "aeat_code": null @@ -174,8 +174,8 @@ { "name": "Retención 2%", "code": "retencion_2", - "value": 200, - "scale": 2, + "value": "200", + "scale": "2", "group": "Retención", "description": "Retención sobre arrendamientos de inmuebles urbanos.", "aeat_code": "R3" @@ -184,8 +184,8 @@ { "name": "REC 5,2%", "code": "rec_5_2", - "value": 520, - "scale": 2, + "value": "520", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo general para IVA 21%.", "aeat_code": "51" @@ -193,8 +193,8 @@ { "name": "REC 1,75%", "code": "rec_1_75", - "value": 175, - "scale": 2, + "value": "175", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo para IVA 10%.", "aeat_code": "52" @@ -202,8 +202,8 @@ { "name": "REC 1,4%", "code": "rec_1_4", - "value": 140, - "scale": 2, + "value": "140", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo para IVA 5%.", "aeat_code": null @@ -211,8 +211,8 @@ { "name": "REC 1%", "code": "rec_1", - "value": 100, - "scale": 2, + "value": "100", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo especial.", "aeat_code": null @@ -220,8 +220,8 @@ { "name": "REC 0,62%", "code": "rec_0_62", - "value": 62, - "scale": 2, + "value": "62", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo para IVA reducido especial.", "aeat_code": null @@ -229,8 +229,8 @@ { "name": "REC 0,5%", "code": "rec_0_5", - "value": 50, - "scale": 2, + "value": "50", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo especial.", "aeat_code": null @@ -238,8 +238,8 @@ { "name": "REC 0,26%", "code": "rec_0_26", - "value": 26, - "scale": 2, + "value": "26", + "scale": "2", "group": "Recargo de equivalencia", "description": "Recargo mínimo.", "aeat_code": null @@ -247,8 +247,8 @@ { "name": "REC 0%", "code": "rec_0", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "Recargo de equivalencia", "description": "Sin recargo.", "aeat_code": null @@ -257,8 +257,8 @@ { "name": "IGIC 7%", "code": "igic_7", - "value": 700, - "scale": 2, + "value": "700", + "scale": "2", "group": "IGIC", "description": "Tipo general IGIC Canarias.", "aeat_code": "10" @@ -266,8 +266,8 @@ { "name": "IGIC 3%", "code": "igic_3", - "value": 300, - "scale": 2, + "value": "300", + "scale": "2", "group": "IGIC", "description": "Tipo reducido IGIC Canarias.", "aeat_code": "11" @@ -275,8 +275,8 @@ { "name": "IGIC 0%", "code": "igic_0", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IGIC", "description": "Operación exenta IGIC.", "aeat_code": "12" @@ -284,8 +284,8 @@ { "name": "IGIC 9,5%", "code": "igic_9_5", - "value": 950, - "scale": 2, + "value": "950", + "scale": "2", "group": "IGIC", "description": "Tipo incrementado IGIC.", "aeat_code": "13" @@ -293,8 +293,8 @@ { "name": "IGIC 13,5%", "code": "igic_13_5", - "value": 1350, - "scale": 2, + "value": "1350", + "scale": "2", "group": "IGIC", "description": "Tipo incrementado especial IGIC.", "aeat_code": "14" @@ -302,8 +302,8 @@ { "name": "IGIC 20%", "code": "igic_20", - "value": 2000, - "scale": 2, + "value": "2000", + "scale": "2", "group": "IGIC", "description": "Tipo incrementado IGIC.", "aeat_code": "15" @@ -311,8 +311,8 @@ { "name": "IGIC 1%", "code": "igic_1", - "value": 100, - "scale": 2, + "value": "100", + "scale": "2", "group": "IGIC", "description": "Tipo reducido IGIC.", "aeat_code": "16" @@ -320,8 +320,8 @@ { "name": "IGIC 2,75%", "code": "igic_2_75", - "value": 275, - "scale": 2, + "value": "275", + "scale": "2", "group": "IGIC", "description": "Tipo especial IGIC.", "aeat_code": null @@ -329,8 +329,8 @@ { "name": "IGIC Exento", "code": "igic_exento", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IGIC", "description": "Operación exenta de IGIC.", "aeat_code": "12" @@ -339,8 +339,8 @@ { "name": "IPSI 10%", "code": "ipsi_10", - "value": 1000, - "scale": 2, + "value": "1000", + "scale": "2", "group": "IPSI", "description": "Tipo general IPSI Ceuta/Melilla.", "aeat_code": null @@ -348,8 +348,8 @@ { "name": "IPSI 4%", "code": "ipsi_4", - "value": 400, - "scale": 2, + "value": "400", + "scale": "2", "group": "IPSI", "description": "Tipo reducido IPSI.", "aeat_code": null @@ -357,8 +357,8 @@ { "name": "IPSI 0,5%", "code": "ipsi_0_5", - "value": 50, - "scale": 2, + "value": "50", + "scale": "2", "group": "IPSI", "description": "Tipo superreducido IPSI.", "aeat_code": null @@ -366,8 +366,8 @@ { "name": "IPSI Exento", "code": "ipsi_exento", - "value": 0, - "scale": 2, + "value": "0", + "scale": "2", "group": "IPSI", "description": "Operación exenta de IPSI.", "aeat_code": null 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 4686f00a..4d9e815c 100644 --- a/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts +++ b/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts @@ -3,8 +3,8 @@ export type TaxItemType = { name: string; // p.ej. "IVA 21%" code: string; // p.ej. "iva_21" - value: number; // porcentaje * 10^scale (21% => 2100) - scale: number; // decimales de 'value' (normalmente 2) + value: string; // porcentaje * 10^scale (21% => 2100) + scale: string; // decimales de 'value' (normalmente 2) group: string; // p.ej. "IVA", "IGIC", "IPSI", "Retención" description?: string; // opcional aeat_code?: string | null; // opcional diff --git a/modules/core/src/common/helpers/index.ts b/modules/core/src/common/helpers/index.ts new file mode 100644 index 00000000..0ad0423e --- /dev/null +++ b/modules/core/src/common/helpers/index.ts @@ -0,0 +1 @@ +export * from "./money-utils"; diff --git a/modules/core/src/common/helpers/money-utils.ts b/modules/core/src/common/helpers/money-utils.ts new file mode 100644 index 00000000..0fa55ce2 --- /dev/null +++ b/modules/core/src/common/helpers/money-utils.ts @@ -0,0 +1,66 @@ +import type { MoneyDTO } from "@erp/core/common"; +import Dinero, { Currency } from "dinero.js"; + +// Tipo compatible con API => MoneyDTO + +// Snapshot mínimo de toObject() en v1 +type DineroPlain = { amount: number; precision: number; currency: string }; + +// --- Helpers --- + +function normalizeDTO(dto: MoneyDTO, fallbackCurrency: Currency = "EUR"): Required { + const v = /^-?\d+$/.test(dto?.value ?? "") ? dto.value : "0"; + const s = /^\d+$/.test(dto?.scale ?? "") ? dto.scale : "2"; + const c = (dto?.currency_code || fallbackCurrency) as string; + return { value: v, scale: s, currency_code: c }; +} + +export function dineroFromDTO(dto: MoneyDTO, fallbackCurrency: Currency = "EUR"): Dinero.Dinero { + const n = normalizeDTO(dto, fallbackCurrency); + return Dinero({ + amount: Number.parseInt(n.value, 10), + precision: Number.parseInt(n.scale, 10), + currency: n.currency_code as Currency, + }); +} + +export function dtoFromDinero(d: Dinero.Dinero): MoneyDTO { + const { amount, precision, currency } = d.toObject() as DineroPlain; + return { + value: amount.toString(), + scale: precision.toString(), + currency_code: currency, + }; +} + +export function sumDTO(list: MoneyDTO[], fallbackCurrency: Currency = "EUR"): MoneyDTO { + if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency }; + const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b)); + return dtoFromDinero(sum); +} + +export function multiplyDTO( + dto: MoneyDTO, + multiplier: number, + rounding: Dinero.RoundingMode = "HALF_EVEN", + fallbackCurrency: Currency = "EUR" +): MoneyDTO { + const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding); + return dtoFromDinero(d); +} + +export function percentageDTO( + dto: MoneyDTO, + percent: number, // 25 = 25% + rounding: Dinero.RoundingMode = "HALF_EVEN", + fallbackCurrency: Currency = "EUR" +): MoneyDTO { + const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding); + return dtoFromDinero(d); +} + +export function formatDTO(dto: MoneyDTO, locale = "es-ES"): string { + const { value, scale, currency_code } = normalizeDTO(dto); + const num = Number(value) / 10 ** Number(scale); // solo presentación + return new Intl.NumberFormat(locale, { style: "currency", currency: currency_code }).format(num); +} diff --git a/modules/core/src/common/index.ts b/modules/core/src/common/index.ts index 6851e148..541d6f57 100644 --- a/modules/core/src/common/index.ts +++ b/modules/core/src/common/index.ts @@ -1,4 +1,5 @@ export * from "./catalogs"; export * from "./dto"; +export * from "./helpers"; export * from "./schemas"; export * from "./types"; diff --git a/modules/core/src/web/hooks/index.ts b/modules/core/src/web/hooks/index.ts index 0f6e6842..4b181058 100644 --- a/modules/core/src/web/hooks/index.ts +++ b/modules/core/src/web/hooks/index.ts @@ -1,6 +1,9 @@ export * from "./use-datasource"; export * from "./use-hook-form"; +export * from "./use-money"; export * from "./use-pagination"; +export * from "./use-percentage"; +export * from "./use-quantity"; export * from "./use-query-key"; export * from "./use-toggle"; export * from "./use-unsaved-changes-notifier"; diff --git a/modules/core/src/web/hooks/use-money.ts b/modules/core/src/web/hooks/use-money.ts new file mode 100644 index 00000000..a789de1d --- /dev/null +++ b/modules/core/src/web/hooks/use-money.ts @@ -0,0 +1,30 @@ +import type { MoneyDTO } from "@erp/core/common"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { + dineroFromDTO, + dtoFromDinero, + formatDTO, + multiplyDTO, + percentageDTO, + sumDTO, +} from "../../common/helpers"; + +// Hook minimal: conversiones y formateo local +export function useMoney() { + const { i18n } = useTranslation(); + const locale = i18n.language || "es-ES"; + + const toDinero = (dto: MoneyDTO) => dineroFromDTO(dto); + const fromDinero = dtoFromDinero; + + 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 format = (dto: MoneyDTO) => formatDTO(dto, locale); + + // Memo ligero (referencias estables) + return useMemo(() => ({ toDinero, fromDinero, sum, multiply, percentage, format }), [locale]); +} diff --git a/modules/core/src/web/hooks/use-percentage.ts b/modules/core/src/web/hooks/use-percentage.ts new file mode 100644 index 00000000..b793ef40 --- /dev/null +++ b/modules/core/src/web/hooks/use-percentage.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import { PercentageDTO } from "../../common"; + +/** + * Hook para porcentajes escalados (value+scale). + */ +export function usePercentage() { + const toNumber = (p?: PercentageDTO | null): number => { + if (!p?.value || !p.scale) return 0; + return Number(p.value) / 10 ** Number(p.scale); + }; + + const fromNumber = (num: number, scale = 2): PercentageDTO => ({ + value: Math.round(num * 10 ** scale).toString(), + scale: scale.toString(), + }); + + const format = (p: PercentageDTO): string => + `${toNumber(p).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}%`; + + return useMemo(() => ({ toNumber, fromNumber, format }), []); +} diff --git a/modules/core/src/web/hooks/use-quantity.ts b/modules/core/src/web/hooks/use-quantity.ts new file mode 100644 index 00000000..4b206ef9 --- /dev/null +++ b/modules/core/src/web/hooks/use-quantity.ts @@ -0,0 +1,39 @@ +import { useMemo } 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); + }; + + const fromNumber = (num: number, scale = 2): QuantityDTO => ({ + value: Math.round(num * 10 ** scale).toString(), + scale: scale.toString(), + }); + + 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() }; + }; + + 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() }; + }; + + const format = (q: QuantityDTO, decimals = 2): string => + toNumber(q).toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + + return useMemo(() => ({ toNumber, fromNumber, add, multiply, format }), []); +} diff --git a/modules/core/src/web/lib/helpers/index.ts b/modules/core/src/web/lib/helpers/index.ts index b6e20656..68621538 100644 --- a/modules/core/src/web/lib/helpers/index.ts +++ b/modules/core/src/web/lib/helpers/index.ts @@ -1,3 +1,2 @@ export * from "./date-func"; export * from "./form-utils"; -export * from "./money-funcs"; diff --git a/modules/core/src/web/lib/helpers/money-funcs.ts b/modules/core/src/web/lib/helpers/money-funcs.ts deleted file mode 100644 index 9e462ea7..00000000 --- a/modules/core/src/web/lib/helpers/money-funcs.ts +++ /dev/null @@ -1,12 +0,0 @@ -import DineroFactory, { Currency } from "dinero.js"; -import { MoneyDTO } from "../../../common"; - -export const formatMoney = (value: MoneyDTO) => { - const money = DineroFactory({ - amount: Number(value.value), - currency: value.currency_code as Currency, - precision: Number(value.scale), - }); - - return money.toFormat(); -}; diff --git a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx index 94301648..edebb5b7 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-list-grid.tsx @@ -11,7 +11,8 @@ import { useCallback, useMemo, useState } from "react"; import { MoneyDTO } from "@erp/core"; -import { formatDate, formatMoney } from "@erp/core/client"; +import { formatDate } from "@erp/core/client"; +import { useMoney } from '@erp/core/hooks'; import { ErrorOverlay } from "@repo/rdx-ui/components"; import { Button } from "@repo/shadcn-ui/components"; import { AgGridReact } from "ag-grid-react"; @@ -25,150 +26,185 @@ import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge"; export const CustomerInvoicesListGrid = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const { format } = useMoney(); const { - data: customersData, - isLoading: isLoadingCustomerInvoices, - isError: isLoadError, - error: loadError, + data: invoices, + isLoading, + isError, + error, } = useCustomerInvoicesQuery({ - pagination: { - pageSize: 999, - }, + pagination: { pageSize: 999 }, }); - // Column Definitions: Defines & controls grid columns. + // Definición de columnas const [colDefs] = useState([ { field: "status", - filter: true, headerName: t("pages.list.grid_columns.status"), - cellRenderer: (params: ValueFormatterParams) => { - return ; - }, + cellRenderer: (params: ValueFormatterParams) => ( + + ), + minWidth: 120, + }, + { + field: "invoice_number", + headerName: t("pages.list.grid_columns.invoice_number"), + minWidth: 130, + }, + { + field: "series", + headerName: t("pages.list.grid_columns.series"), + minWidth: 80, }, - - { field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") }, - { field: "series", headerName: t("pages.list.grid_columns.series") }, - { field: "invoice_date", headerName: t("pages.list.grid_columns.invoice_date"), - valueFormatter: (params: ValueFormatterParams) => { - return formatDate(params.value); - }, + valueFormatter: (p: ValueFormatterParams) => formatDate(p.value), + minWidth: 130, + }, + { + field: "recipient.tin", + headerName: t("pages.list.grid_columns.recipient_tin"), + minWidth: 130, + }, + { + field: "recipient.name", + headerName: t("pages.list.grid_columns.recipient_name"), + minWidth: 200, + }, + { + field: "recipient.city", + headerName: t("pages.list.grid_columns.recipient_city"), + minWidth: 130, + }, + { + field: "recipient.province", + headerName: t("pages.list.grid_columns.recipient_province"), + minWidth: 130, }, - { field: "recipient.tin", headerName: t("pages.list.grid_columns.recipient_tin") }, - { field: "recipient.name", headerName: t("pages.list.grid_columns.recipient_name") }, - { field: "recipient.city", headerName: t("pages.list.grid_columns.recipient_city") }, - { field: "recipient.province", headerName: t("pages.list.grid_columns.recipient_province") }, { field: "recipient.postal_code", headerName: t("pages.list.grid_columns.recipient_postal_code"), + minWidth: 100, }, { field: "taxable_amount", headerName: t("pages.list.grid_columns.taxable_amount"), + type: "rightAligned", valueFormatter: (params: ValueFormatterParams) => { - const rawValue: MoneyDTO = params.value; - return formatMoney(rawValue); + const raw: MoneyDTO | null = params.value; + return raw ? format(raw) : "—"; }, + cellClass: "tabular-nums", + minWidth: 130, }, { field: "taxes_amount", - headerName: t("pages.list.grid_columns.taxable_amount"), + headerName: t("pages.list.grid_columns.taxes_amount"), + type: "rightAligned", valueFormatter: (params: ValueFormatterParams) => { - const rawValue: MoneyDTO = params.value; - return formatMoney(rawValue); + const raw: MoneyDTO | null = params.value; + return raw ? format(raw) : "—"; }, + cellClass: "tabular-nums", + minWidth: 130, }, - { field: "total_amount", headerName: t("pages.list.grid_columns.total_amount"), + type: "rightAligned", valueFormatter: (params: ValueFormatterParams) => { - const rawValue: MoneyDTO = params.value; - return formatMoney(rawValue); + const raw: MoneyDTO | null = params.value; + return raw ? format(raw) : "—"; }, + cellClass: "tabular-nums font-semibold", + minWidth: 140, }, { colId: "actions", - headerName: t("pages.list.grid_columns.actions", "Actions"), + headerName: t("pages.list.grid_columns.actions", "Acciones"), cellRenderer: (params: ValueFormatterParams) => { - const { data } = params; + const id = params.data?.id; + if (!id) return null; return ( ); }, + minWidth: 80, + maxWidth: 80, + pinned: "right", }, ]); - // Navegación centralizada (click/teclado) + // Navegación accesible (click o teclado) const goToRow = useCallback( (id: string, newTab = false) => { const url = `/customer-invoices/${id}/edit`; - if (newTab) { - window.open(url, "_blank", "noopener,noreferrer"); - } else { - navigate(url); - } + newTab + ? window.open(url, "_blank", "noopener,noreferrer") + : navigate(url); }, [navigate] ); const onRowClicked = useCallback( - (e: RowClickedEvent) => { + (e: RowClickedEvent) => { if (!e.data) return; - // Soporta Ctrl/Cmd click para nueva pestaña - const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey); + const newTab = + e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey); goToRow(e.data.id, newTab); }, [goToRow] ); const onCellKeyDown = useCallback( - (e: CellKeyDownEvent) => { + (e: CellKeyDownEvent) => { if (!e.data) return; - const key = e.event.key; - // Enter o Space disparan navegación + + const ev = e.event; + if (!ev || !(ev instanceof KeyboardEvent)) return; + + const key = ev.key; if (key === "Enter" || key === " ") { - e.event.preventDefault(); + ev.preventDefault(); goToRow(e.data.id); } - // Ctrl/Cmd+Enter abre en nueva pestaña - if ((e.event.ctrlKey || e.event.metaKey) && key === "Enter") { - e.event.preventDefault(); + if ((ev.ctrlKey || ev.metaKey) && key === "Enter") { + ev.preventDefault(); goToRow(e.data.id, true); } }, [goToRow] ); + // Estrategia de autoajuste de columnas const autoSizeStrategy = useMemo< | SizeColumnsToFitGridStrategy | SizeColumnsToFitProvidedWidthStrategy | SizeColumnsToContentStrategy - >(() => { - return { + >( + () => ({ type: "fitGridWidth", defaultMinWidth: 100, - columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }], - }; - }, []); + columnLimits: [{ colId: "actions", minWidth: 80, maxWidth: 80 }], + }), + [] + ); + // Config general de AG Grid const gridOptions: GridOptions = useMemo( () => ({ columnDefs: colDefs, - autoSizeStrategy: autoSizeStrategy, + autoSizeStrategy, defaultColDef: { editable: false, flex: 1, @@ -177,52 +213,41 @@ export const CustomerInvoicesListGrid = () => { resizable: true, }, pagination: true, - paginationPageSize: 15, - paginationPageSizeSelector: [10, 15, 20, 30, 50], + paginationPageSize: 20, + paginationPageSizeSelector: [10, 20, 30, 50], localeText: AG_GRID_LOCALE_ES, - - // Evita conflictos con selección si la usas suppressRowClickSelection: true, - // Clase visual de fila clickeable getRowClass: () => "clickable-row", - // Accesibilidad con teclado onCellKeyDown, - // Click en cualquier parte de la fila onRowClicked, - // IDs estables (opcional pero recomendado) - getRowId: (params) => params.data.id, - + getRowId: (p) => p.data.id, }), - [autoSizeStrategy, colDefs] + [autoSizeStrategy, colDefs, onCellKeyDown, onRowClicked] ); - if (isLoadError) { + // Error al cargar + if (isError) { return ( - <> - - + ); } - // Container: Defines the grid's theme & dimensions. + // Render principal return ( -
-
+ ); -}; +}; \ No newline at end of file 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 ac5c4c24..894aec44 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,79 +2,56 @@ 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, useWatch } from "react-hook-form"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useCalculateItemAmounts } from '../../hooks'; import { useTranslation } from "../../i18n"; -import { CustomerInvoiceFormData } from "../../schemas"; +import { CustomerInvoiceItemFormData } from "../../schemas"; import { BlocksView, TableView } from "./items"; export const InvoiceItems = () => { const [viewMode, setViewMode] = useState<"blocks" | "table">("table"); const { t } = useTranslation(); - const { control } = useFormContext(); + const calculateItemAmounts = useCalculateItemAmounts(); - - - const invoice = useWatch({ control }); - - const { fields: items, ...fieldActions } = useFieldArray({ + const { control, setValue, watch } = useFormContext(); + const { fields, append, remove, insert, move } = useFieldArray({ control, name: "items", }); - const addItem = () => { - /*const newItem = { - position: invoice.items ? invoice.items.length + 1 : 0, - description: "", - quantity: 1, - unit_amount: 0, - taxes: [], - subtotal_amount: 0, - discount_percentage: 0, - discount_amount: 0, - taxable_amount: 0, - taxes_amount: 0, - total_amount: 0, - }; + const items = watch("items") as CustomerInvoiceItemFormData[]; - setInvoice({ - ...invoice, - items: [...invoice.items, newItem], - });*/ + 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) => { - /*const newItems = invoice.items.filter((_: any, i: number) => i !== index); - setInvoice({ - ...invoice, - items: newItems, - });*/ + remove(index) }; - const updateItem = (index: number, field: string, value: any) => { - /*const newItems = [...invoice.items]; - newItems[index] = { ...newItems[index], [field]: value }; - // Recalculate amounts - const item = newItems[index]; - item.subtotal_amount = item.quantity * item.unit_amount; - item.discount_amount = (item.subtotal_amount * item.discount_percentage) / 100; - item.taxable_amount = item.subtotal_amount - item.discount_amount; - item.taxes_amount = item.taxable_amount * 0.21; // Mock 21% tax - item.total_amount = item.taxable_amount + item.taxes_amount; - - setInvoice({ - ...invoice, - items: newItems, - });*/ - }; - - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("es-ES", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }).format(amount); + 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 ( @@ -106,18 +83,28 @@ export const InvoiceItems = () => { Tabla - - + {viewMode === "blocks" ? ( - + ) : ( - + )} 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 new file mode 100644 index 00000000..b74b6172 --- /dev/null +++ b/modules/customer-invoices/src/web/components/editor/items/hover-card-total-summary.tsx @@ -0,0 +1,90 @@ +import { useMoney } from '@erp/core/hooks'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, + HoverCard, HoverCardContent, HoverCardTrigger +} from "@repo/shadcn-ui/components"; +import { PropsWithChildren } from 'react'; +import { useInvoiceItemSummary } from '../../../hooks'; +import { CustomerInvoiceItemFormData } from '../../../schemas'; + + +type HoverCardTotalsSummaryProps = PropsWithChildren & { + item: CustomerInvoiceItemFormData +} + + +export const HoverCardTotalsSummary = ({ + item, + children, +}: HoverCardTotalsSummaryProps) => { + const { format } = useMoney() + const summary = useInvoiceItemSummary(item) + + const SummaryBlock = () => ( +
+

Desglose del importe

+ +
+ Subtotal: + {format(summary.subtotal)} +
+ + {Number(item.discount_percentage?.value ?? 0) > 0 && ( +
+ + Descuento ({item.discount_percentage.value ?? 0}%): + + + -{format(summary.discountAmount)} + +
+ )} + +
+ Base imponible: + + {format(summary.baseAmount)} + +
+ + {summary.taxesBreakdown.map((tax) => ( +
+ {tax.label}: + {format(tax.amount)} +
+ ))} + +
+ Total: + {format(summary.total)} +
+
+ ) + + return ( + <> + {/* Variante móvil */} +
+ + {children} + + + Desglose del importe + + + + +
+ + {/* Variante desktop */} +
+ + {children} + + + + +
+ + ) +} \ No newline at end of file diff --git a/modules/customer-invoices/src/web/components/editor/items/table-view.tsx b/modules/customer-invoices/src/web/components/editor/items/table-view.tsx index 89b0cf15..87540b1c 100644 --- a/modules/customer-invoices/src/web/components/editor/items/table-view.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/table-view.tsx @@ -1,141 +1,301 @@ import { - Badge, Button, Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@repo/shadcn-ui/components"; +import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react"; -import { Trash2Icon } from "lucide-react"; + +import { useMoney } from '@erp/core/hooks'; +import { useEffect, useState } from 'react'; +import { useCalculateItemAmounts } from '../../../hooks'; +import { CustomerInvoiceItemFormData } from '../../../schemas'; +import { HoverCardTotalsSummary } from './hover-card-total-summary'; import { CustomItemViewProps } from "./types"; -export interface TableViewProps extends CustomItemViewProps {} +export interface TableViewProps extends CustomItemViewProps { } -const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("es-ES", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }).format(amount); -}; +export const TableView = ({ items, actions }: TableViewProps) => { + const { format } = useMoney(); + const calculateItemAmounts = useCalculateItemAmounts(); + const [lines, setLines] = useState(items); + + useEffect(() => { + setLines(items) + }, [items]) + + // Mantiene sincronía con el formulario padre + const updateItems = (updated: CustomerInvoiceItemFormData[]) => { + setLines(updated); + onItemsChange(updated); + }; + + /** 🔹 Actualiza una fila con recalculo */ + const updateItem = (index: number, patch: Partial) => { + const newItems = [...lines]; + const merged = { ...newItems[index], ...patch }; + newItems[index] = calculateItemAmounts(merged as CustomerInvoiceItemFormData); + updateItems(newItems); + }; + + /** 🔹 Mueve la fila hacia arriba o abajo */ + const moveItem = (index: number, direction: "up" | "down") => { + if ( + (direction === "up" && index === 0) || + (direction === "down" && index === lines.length - 1) + ) + return; + + const newItems = [...lines]; + const target = direction === "up" ? index - 1 : index + 1; + [newItems[index], newItems[target]] = [newItems[target], newItems[index]]; + updateItems(newItems); + }; + + /** 🔹 Duplica una línea */ + const duplicateItem = (index: number) => { + const newItems = [...lines]; + const copy = structuredClone(newItems[index]); + newItems.splice(index + 1, 0, copy); + updateItems(newItems); + }; + + /** 🔹 Elimina una línea */ + const removeItem = (index: number) => { + updateItems(lines.filter((_, i) => i !== index)); + }; + + /** 🔹 Añade una nueva línea vacía */ + 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"], + }; + updateItems([...lines, calculateItemAmounts(newItem)]); + }; -export const TableView = ({ items, removeItem, updateItem }: TableViewProps) => { return ( -
- - - - - - - - - - - - - - - - - - - {items.map((item: any, index: number) => ( - - - - - - - - - - - - - - - ))} - -
#DescripciónCantidadPrecio Unit.% Desc.ImpuestosSubtotalDescuentoBase Imp.ImpuestosTotalAcciones
- - {item.position} - - - updateItem(index, "description", e.target.value)} - placeholder='Descripción...' - className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0' - /> - - - updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0) - } - className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' - /> - - - updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0) - } - className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' - /> - - - updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0) - } - className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' - /> - - - - {formatCurrency(item.subtotal_amount)} - - {formatCurrency(item.discount_amount)} - - {formatCurrency(item.taxable_amount)} - - {formatCurrency(item.taxes_amount)} - - {formatCurrency(item.total_amount)} - - {items.length > 1 && ( - - )} -
+
+
+ + + + # + Descripción + Cantidad + Precio Unit. + % Desc. + Total + Acciones + + + + + {lines.map((item, i) => ( + + {/* ÍNDICE */} + + {i + 1} + + + {/* DESCRIPCIÓN */} + + +