Facturas de cliente
This commit is contained in:
parent
a28e03eddd
commit
ec86f74830
@ -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",
|
||||
|
||||
@ -91,8 +91,8 @@ export class Tax extends ValueObject<TaxProps> {
|
||||
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
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
1
modules/core/src/common/helpers/index.ts
Normal file
1
modules/core/src/common/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./money-utils";
|
||||
66
modules/core/src/common/helpers/money-utils.ts
Normal file
66
modules/core/src/common/helpers/money-utils.ts
Normal file
@ -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<MoneyDTO> {
|
||||
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);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./catalogs";
|
||||
export * from "./dto";
|
||||
export * from "./helpers";
|
||||
export * from "./schemas";
|
||||
export * from "./types";
|
||||
|
||||
@ -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";
|
||||
|
||||
30
modules/core/src/web/hooks/use-money.ts
Normal file
30
modules/core/src/web/hooks/use-money.ts
Normal file
@ -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]);
|
||||
}
|
||||
25
modules/core/src/web/hooks/use-percentage.ts
Normal file
25
modules/core/src/web/hooks/use-percentage.ts
Normal file
@ -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 }), []);
|
||||
}
|
||||
39
modules/core/src/web/hooks/use-quantity.ts
Normal file
39
modules/core/src/web/hooks/use-quantity.ts
Normal file
@ -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 }), []);
|
||||
}
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./date-func";
|
||||
export * from "./form-utils";
|
||||
export * from "./money-funcs";
|
||||
|
||||
@ -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();
|
||||
};
|
||||
@ -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<ColDef[]>([
|
||||
{
|
||||
field: "status",
|
||||
filter: true,
|
||||
headerName: t("pages.list.grid_columns.status"),
|
||||
cellRenderer: (params: ValueFormatterParams) => {
|
||||
return <CustomerInvoiceStatusBadge status={params.value} />;
|
||||
},
|
||||
cellRenderer: (params: ValueFormatterParams) => (
|
||||
<CustomerInvoiceStatusBadge status={params.value} />
|
||||
),
|
||||
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 (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={() => {
|
||||
navigate(`/customer-invoices/${data.id}/edit`);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t("pages.list.open_invoice", "Abrir factura")}
|
||||
onClick={() => navigate(`/customer-invoices/${id}/edit`)}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
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<unknown>) => {
|
||||
(e: RowClickedEvent<any>) => {
|
||||
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<unknown>) => {
|
||||
(e: CellKeyDownEvent<any>) => {
|
||||
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 (
|
||||
<>
|
||||
<ErrorOverlay
|
||||
errorMessage={
|
||||
(loadError as Error)?.message ??
|
||||
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<ErrorOverlay
|
||||
errorMessage={
|
||||
(error as Error)?.message ??
|
||||
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Container: Defines the grid's theme & dimensions.
|
||||
// Render principal
|
||||
return (
|
||||
<div
|
||||
className='ag-theme-alpine'
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
<section
|
||||
className="ag-theme-alpine ag-theme-shadcn w-full h-full"
|
||||
aria-label={t("pages.list.aria_label", "Listado de facturas de cliente")}
|
||||
>
|
||||
<AgGridReact
|
||||
rowData={customersData?.items ?? []}
|
||||
loading={isLoadingCustomerInvoices}
|
||||
rowData={invoices?.items ?? []}
|
||||
loading={isLoading}
|
||||
{...gridOptions}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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<CustomerInvoiceFormData>();
|
||||
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<CustomerInvoiceItemFormData>) => {
|
||||
const updated = { ...items[index], ...patch };
|
||||
const recalculated = calculateItemAmounts(updated as CustomerInvoiceItemFormData);
|
||||
setValue(`${"items"}.${index}`, recalculated, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const duplicateItem = (index: number) => {
|
||||
const copy = structuredClone(items[index]);
|
||||
insert(index + 1, copy);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
/*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
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={addItem} size='sm'>
|
||||
<Button onClick={addNewItem} size='sm'>
|
||||
<PlusIcon className='h-4 w-4 mr-2' />
|
||||
Añadir Línea
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className='overflow-auto'>
|
||||
{viewMode === "blocks" ? (
|
||||
<BlocksView items={items} removeItem={removeItem} updateItem={updateItem} />
|
||||
<BlocksView items={items} actions={
|
||||
addNewItem,
|
||||
updateItem,
|
||||
duplicateItem,
|
||||
removeItem
|
||||
} />
|
||||
) : (
|
||||
<TableView items={items} removeItem={removeItem} updateItem={updateItem} />
|
||||
<TableView items={items} actions={
|
||||
addNewItem,
|
||||
updateItem,
|
||||
duplicateItem,
|
||||
removeItem
|
||||
} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -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 = () => (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold mb-3">Desglose del importe</h4>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-mono">{format(summary.subtotal)}</span>
|
||||
</div>
|
||||
|
||||
{Number(item.discount_percentage?.value ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Descuento ({item.discount_percentage.value ?? 0}%):
|
||||
</span>
|
||||
<span className="font-mono text-destructive">
|
||||
-{format(summary.discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-sm border-t pt-2">
|
||||
<span className="text-muted-foreground">Base imponible:</span>
|
||||
<span className="font-mono font-medium">
|
||||
{format(summary.baseAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{summary.taxesBreakdown.map((tax) => (
|
||||
<div key={tax.label} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{tax.label}:</span>
|
||||
<span className="font-mono">{format(tax.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono">{format(summary.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Variante móvil */}
|
||||
<div className="md:hidden">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desglose del importe</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SummaryBlock />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variante desktop */}
|
||||
<div className="hidden md:block">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
<HoverCardContent className="w-64" align="end">
|
||||
<SummaryBlock />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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<CustomerInvoiceItemFormData[]>(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<CustomerInvoiceItemFormData>) => {
|
||||
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 (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border-collapse'>
|
||||
<thead>
|
||||
<tr className='border-b bg-muted/30'>
|
||||
<th className='text-left p-3 text-sm font-medium'>#</th>
|
||||
<th className='text-left p-3 text-sm font-medium min-w-[200px]'>Descripción</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-24'>Cantidad</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32'>Precio Unit.</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-24'>% Desc.</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32'>Impuestos</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Subtotal</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Descuento</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Base Imp.</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Impuestos</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-32'>Total</th>
|
||||
<th className='text-left p-3 text-sm font-medium w-16'>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item: any, index: number) => (
|
||||
<tr key={`item-${String(index)}`} className='border-b hover:bg-muted/20'>
|
||||
<td className='p-3'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
{item.position}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(index, "description", e.target.value)}
|
||||
placeholder='Descripción...'
|
||||
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0'
|
||||
/>
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right'
|
||||
/>
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.0001'
|
||||
value={item.unit_amount}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.0001'
|
||||
value={item.discount_percentage}
|
||||
onChange={(e) =>
|
||||
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'
|
||||
/>
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
<Select>
|
||||
<SelectTrigger className='border-0 bg-transparent p-0 h-auto focus:ring-0'>
|
||||
<SelectValue placeholder='IVA 21%' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='iva21'>IVA 21%</SelectItem>
|
||||
<SelectItem value='iva10'>IVA 10%</SelectItem>
|
||||
<SelectItem value='iva4'>IVA 4%</SelectItem>
|
||||
<SelectItem value='exento'>Exento</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className='sr-only p-3 text-right text-sm font-medium'>
|
||||
{formatCurrency(item.subtotal_amount)}
|
||||
</td>
|
||||
<td className='sr-only p-3 text-right text-sm font-medium'>
|
||||
{formatCurrency(item.discount_amount)}
|
||||
</td>
|
||||
<td className='sr-only p-3 text-right text-sm font-medium'>
|
||||
{formatCurrency(item.taxable_amount)}
|
||||
</td>
|
||||
<td className='sr-only p-3 text-right text-sm font-medium'>
|
||||
{formatCurrency(item.taxes_amount)}
|
||||
</td>
|
||||
<td className='p-3 text-right text-sm font-semibold text-primary'>
|
||||
{formatCurrency(item.total_amount)}
|
||||
</td>
|
||||
<td className='p-3'>
|
||||
{items.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeItem(index)}
|
||||
className='text-destructive hover:text-destructive h-8 w-8 p-0'
|
||||
>
|
||||
<Trash2Icon className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
|
||||
<TableRow className="bg-muted/30 text-xs text-muted-foreground">
|
||||
<TableHead className="w-10 text-center">#</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead className="text-right w-24">Cantidad</TableHead>
|
||||
<TableHead className="text-right w-32">Precio Unit.</TableHead>
|
||||
<TableHead className="text-right w-24">% Desc.</TableHead>
|
||||
<TableHead className="text-right w-32">Total</TableHead>
|
||||
<TableHead className="w-44 text-center">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{lines.map((item, i) => (
|
||||
<TableRow key={`item-${i}`} className="text-sm hover:bg-muted/40">
|
||||
{/* ÍNDICE */}
|
||||
<TableCell className="text-center text-muted-foreground font-mono align-text-top">
|
||||
{i + 1}
|
||||
</TableCell>
|
||||
|
||||
{/* DESCRIPCIÓN */}
|
||||
|
||||
<TableCell className="align-top">
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(i, { description: e.target.value })}
|
||||
placeholder="Descripción del producto o servicio…"
|
||||
className="min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
|
||||
px-2 py-0
|
||||
leading-5 overflow-y-auto"
|
||||
aria-label={`Descripción línea ${i + 1}`}
|
||||
spellCheck={true}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
|
||||
</TableCell>
|
||||
|
||||
{/* CANTIDAD */}
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
|
||||
onChange={(e) =>
|
||||
updateItem(i, {
|
||||
quantity: {
|
||||
value: (
|
||||
Number(e.target.value) * 10 ** Number(item.quantity.scale)
|
||||
).toString(),
|
||||
scale: item.quantity.scale,
|
||||
},
|
||||
})
|
||||
}
|
||||
aria-label={`Cantidad línea ${i + 1}`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* PRECIO UNITARIO */}
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
|
||||
onChange={(e) =>
|
||||
updateItem(i, {
|
||||
unit_amount: {
|
||||
...item.unit_amount,
|
||||
value: (
|
||||
Number(e.target.value) * 10 ** Number(item.unit_amount.scale)
|
||||
).toString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
aria-label={`Precio unitario línea ${i + 1}`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* DESCUENTO */}
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
|
||||
value={
|
||||
Number(item.discount_percentage.value) /
|
||||
10 ** Number(item.discount_percentage.scale)
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateItem(i, {
|
||||
discount_percentage: {
|
||||
value: (
|
||||
Number(e.target.value) *
|
||||
10 ** Number(item.discount_percentage.scale)
|
||||
).toString(),
|
||||
scale: item.discount_percentage.scale,
|
||||
},
|
||||
})
|
||||
}
|
||||
aria-label={`Descuento línea ${i + 1}`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* TOTAL */}
|
||||
<TableCell className="text-right font-mono">
|
||||
<HoverCardTotalsSummary item={item}>
|
||||
<span className="cursor-help hover:text-primary transition-colors">
|
||||
{format(item.total_amount)}
|
||||
</span>
|
||||
</HoverCardTotalsSummary>
|
||||
</TableCell>
|
||||
|
||||
{/* ACCIONES */}
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => moveItem(i, "up")}
|
||||
disabled={i === 0}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mover arriba</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => moveItem(i, "down")}
|
||||
disabled={i === lines.length - 1}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mover abajo</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => duplicateItem(i)}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicar línea</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeItem(i)}
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Eliminar línea</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={addNewItem}
|
||||
variant="outline"
|
||||
className="w-full border-dashed bg-transparent"
|
||||
aria-label="Agregar nueva línea"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Agregar línea
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
export interface CustomItemViewProps {
|
||||
items: any;
|
||||
updateItem: (index: number, field: string, value: any) => void;
|
||||
removeItem: (index: number) => void;
|
||||
items: CustomerInvoiceItemFormData[];
|
||||
onItemsChange: (items: InvoiceItem[]) => void;
|
||||
updateItem?: (index: number, field: string, value: any) => void;
|
||||
removeItem?: (index: number) => void;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||
// features/common/components/page-header.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
||||
@ -13,11 +14,13 @@ interface PageHeaderProps {
|
||||
status?: string;
|
||||
/** Contenido del lado derecho (botones, menús, etc.) */
|
||||
rightSlot?: ReactNode;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ icon, title, description, status, rightSlot }: PageHeaderProps) {
|
||||
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className='border-b bg-card -px-4'>
|
||||
<div className={cn("border-b bg-card -px-4", className)}>
|
||||
<div className='mx-auto w-full px-6 pt-2 pb-8'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Lado izquierdo */}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export * from "./use-calculate-item-amounts";
|
||||
export * from "./use-create-customer-invoice-mutation";
|
||||
export * from "./use-customer-invoice-item-summary";
|
||||
export * from "./use-customer-invoice-query";
|
||||
export * from "./use-customer-invoices-context";
|
||||
export * from "./use-customer-invoices-query";
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import { spainTaxCatalogProvider } from "@erp/core";
|
||||
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { CustomerInvoiceItemFormData } from "../schemas";
|
||||
|
||||
/**
|
||||
* Recalcula todos los importes de una línea usando los hooks de escala.
|
||||
*/
|
||||
export function useCalculateItemAmounts() {
|
||||
const { multiply, percentage: moneyPct, sum } = useMoney();
|
||||
const { toNumber: qtyToNumber } = useQuantity();
|
||||
const { toNumber: pctToNumber } = usePercentage();
|
||||
|
||||
const taxCatalog = useMemo(() => spainTaxCatalogProvider, []);
|
||||
|
||||
return (item: CustomerInvoiceItemFormData): CustomerInvoiceItemFormData => {
|
||||
const qty = qtyToNumber(item.quantity);
|
||||
const subtotal = multiply(item.unit_amount, qty);
|
||||
const discountPct = pctToNumber(item.discount_percentage);
|
||||
const discountAmount =
|
||||
discountPct > 0 ? moneyPct(subtotal, discountPct) : { ...subtotal, value: "0" };
|
||||
|
||||
const base = sum([
|
||||
subtotal,
|
||||
{ ...discountAmount, value: (-Number(discountAmount.value)).toString() },
|
||||
]);
|
||||
|
||||
// Impuestos (cada uno es porcentaje sobre la base)
|
||||
const taxesBreakdown =
|
||||
item.tax_codes?.map((tax_code) => {
|
||||
const maybeTax = taxCatalog.findByCode(tax_code);
|
||||
|
||||
if (maybeTax.isNone()) {
|
||||
throw Error(`Código de impuesto no encontrado en el catálogo: "${tax_code}"`);
|
||||
}
|
||||
|
||||
const tax = maybeTax.unwrap()!;
|
||||
const percentage = pctToNumber({ value: tax.value, scale: tax.scale });
|
||||
|
||||
return {
|
||||
label: tax.name,
|
||||
percentage,
|
||||
amount: moneyPct(base, percentage),
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const taxes = sum(taxesBreakdown.map((t) => t.amount));
|
||||
const total = sum([base, taxes]);
|
||||
|
||||
return {
|
||||
...item,
|
||||
subtotal_amount: subtotal,
|
||||
discount_amount: discountAmount,
|
||||
taxable_amount: base,
|
||||
taxes_amount: taxes,
|
||||
total_amount: total,
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import { MoneyDTO, spainTaxCatalogProvider } from "@erp/core";
|
||||
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { CustomerInvoiceItem, CustomerInvoiceItemFormData } from "../schemas";
|
||||
|
||||
/**
|
||||
* Calcula subtotal, descuento, base imponible, impuestos y total de una línea de factura.
|
||||
* Trabaja con DTOs escalados (value+scale) como los del backend.
|
||||
*/
|
||||
export function useInvoiceItemSummary(item: CustomerInvoiceItem | CustomerInvoiceItemFormData) {
|
||||
const { multiply, percentage: moneyPercentage, sum } = useMoney();
|
||||
const { toNumber: qtyToNumber } = useQuantity();
|
||||
const { toNumber: pctToNumber } = usePercentage();
|
||||
|
||||
const taxCatalog = useMemo(() => spainTaxCatalogProvider, []);
|
||||
|
||||
return useMemo(() => {
|
||||
// 🔹 Cantidad decimal (ej. "100" con scale "2" → 1.00)
|
||||
const qty = qtyToNumber(item.quantity);
|
||||
|
||||
// 🔹 Subtotal = cantidad × precio unitario
|
||||
const subtotal = multiply(item.unit_amount, qty);
|
||||
|
||||
// 🔹 Descuento = subtotal × (discount_percentage / 100)
|
||||
const discountPct = item.discount_percentage
|
||||
? item.discount_percentage
|
||||
: { value: "0", scale: "2" }; // fallback DTO
|
||||
|
||||
const discountAmount =
|
||||
pctToNumber(discountPct) > 0
|
||||
? moneyPercentage(subtotal, pctToNumber(discountPct))
|
||||
: ({ ...subtotal, value: "0" } as MoneyDTO);
|
||||
|
||||
// 🔹 Base imponible = subtotal - descuento
|
||||
const baseAmount = sum([
|
||||
subtotal,
|
||||
{ ...discountAmount, value: (-Number(discountAmount.value)).toString() },
|
||||
]);
|
||||
|
||||
// 🔹 Impuestos (cada uno es porcentaje sobre base)
|
||||
const taxesBreakdown =
|
||||
item.tax_codes?.map((tax_code) => {
|
||||
const maybeTax = taxCatalog.findByCode(tax_code);
|
||||
|
||||
if (maybeTax.isNone()) {
|
||||
throw Error(`Código de impuesto no encontrado en el catálogo: "${tax_code}"`);
|
||||
}
|
||||
|
||||
const tax = maybeTax.unwrap()!;
|
||||
const percentage = pctToNumber({ value: tax.value, scale: tax.scale });
|
||||
|
||||
return {
|
||||
label: tax.name,
|
||||
percentage,
|
||||
amount: moneyPercentage(baseAmount, percentage),
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const taxesTotal = sum(taxesBreakdown.map((t) => t.amount));
|
||||
|
||||
// 🔹 Total = base + impuestos
|
||||
const total = sum([baseAmount, taxesTotal]);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
discountAmount,
|
||||
baseAmount,
|
||||
taxesBreakdown,
|
||||
taxesTotal,
|
||||
total,
|
||||
};
|
||||
}, [item, multiply, moneyPercentage, sum, qtyToNumber, pctToNumber, taxCatalog.findByCode]);
|
||||
}
|
||||
@ -132,7 +132,7 @@ export const CustomerInvoiceUpdatePage = () => {
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||
<AppHeader>
|
||||
<AppHeader className="sticky">
|
||||
<AppBreadcrumb />
|
||||
<PageHeader
|
||||
status={invoiceData.status}
|
||||
|
||||
@ -17,6 +17,8 @@ export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchem
|
||||
|
||||
// Tipos (derivados de Zod o DTOs del backend)
|
||||
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>;
|
||||
export type CustomerInvoiceItem = ArrayElement<CustomerInvoice["items"]>;
|
||||
|
||||
export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear
|
||||
export type CustomerInvoiceUpdateInput = z.infer<typeof CustomerInvoiceUpdateSchema>; // Cuerpo para actualizar
|
||||
|
||||
|
||||
@ -1,5 +1,32 @@
|
||||
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const CustomerInvoiceItemFormSchema = z.object({
|
||||
isNonValued: z.boolean().optional(),
|
||||
|
||||
description: z.string(),
|
||||
quantity: QuantitySchema,
|
||||
unit_amount: MoneySchema,
|
||||
|
||||
tax_codes: z.array(z.string()).default([]),
|
||||
taxes: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
percentage: z.number(),
|
||||
amount: MoneySchema.optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
||||
subtotal_amount: MoneySchema,
|
||||
discount_percentage: PercentageSchema,
|
||||
discount_amount: MoneySchema,
|
||||
taxable_amount: MoneySchema,
|
||||
taxes_amount: MoneySchema,
|
||||
total_amount: MoneySchema,
|
||||
});
|
||||
|
||||
export const CustomerInvoiceFormSchema = z.object({
|
||||
invoice_number: z.string().optional(),
|
||||
status: z.string(),
|
||||
@ -13,7 +40,7 @@ export const CustomerInvoiceFormSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
|
||||
/*language_code: z
|
||||
language_code: z
|
||||
.string({
|
||||
error: "El idioma es obligatorio",
|
||||
})
|
||||
@ -29,7 +56,7 @@ export const CustomerInvoiceFormSchema = z.object({
|
||||
.toUpperCase() // asegura mayúsculas
|
||||
.default("EUR"),
|
||||
|
||||
taxes: z
|
||||
/*taxes: z
|
||||
.array(
|
||||
z.object({
|
||||
tax_code: z.string(),
|
||||
@ -38,36 +65,20 @@ export const CustomerInvoiceFormSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
*/
|
||||
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
position: z.string(),
|
||||
description: z.string(),
|
||||
quantity: QuantitySchema,
|
||||
unit_amount: MoneySchema,
|
||||
|
||||
tax_codes: z.array(z.string()).default([]),
|
||||
|
||||
subtotal_amount: MoneySchema,
|
||||
discount_percentage: PercentageSchema,
|
||||
discount_amount: MoneySchema,
|
||||
taxable_amount: MoneySchema,
|
||||
taxes_amount: MoneySchema,
|
||||
total_amount: MoneySchema,
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
items: z.array(CustomerInvoiceItemFormSchema).optional(),
|
||||
|
||||
subtotal_amount: MoneySchema,
|
||||
discount_percentage: PercentageSchema,
|
||||
discount_amount: MoneySchema,
|
||||
taxable_amount: MoneySchema,
|
||||
taxes_amount: MoneySchema,
|
||||
total_amount: MoneySchema,*/
|
||||
total_amount: MoneySchema,
|
||||
});
|
||||
|
||||
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
|
||||
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
|
||||
|
||||
export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
|
||||
invoice_number: "",
|
||||
@ -83,7 +94,7 @@ export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
|
||||
language_code: "es",
|
||||
currency_code: "EUR",
|
||||
|
||||
taxes: [],
|
||||
//taxes: [],
|
||||
|
||||
items: [],
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ interface CustomerModalSelectorProps {
|
||||
initialCustomer?: CustomerSummary;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
className: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CustomerModalSelector = ({
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
Separator,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { ChevronDown, MailIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@ -35,13 +35,14 @@ export const CustomerContactFields = () => {
|
||||
label={t("form_fields.email_primary.label")}
|
||||
placeholder={t("form_fields.email_primary.placeholder")}
|
||||
description={t("form_fields.email_primary.description")}
|
||||
icon={<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
|
||||
icon={<AtSignIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
|
||||
typePreset='email'
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className='lg:col-span-2'
|
||||
typePreset='phone'
|
||||
control={control}
|
||||
name='mobile_primary'
|
||||
label={t("form_fields.mobile_primary.label")}
|
||||
@ -56,6 +57,7 @@ export const CustomerContactFields = () => {
|
||||
/>
|
||||
|
||||
<TextField
|
||||
typePreset='phone'
|
||||
className='lg:col-span-2'
|
||||
control={control}
|
||||
name='phone_primary'
|
||||
@ -70,16 +72,18 @@ export const CustomerContactFields = () => {
|
||||
<Separator className='mt-6' />
|
||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||
<TextField
|
||||
typePreset='email'
|
||||
className='lg:col-span-2 lg:col-start-1'
|
||||
control={control}
|
||||
name='email_secondary'
|
||||
label={t("form_fields.email_secondary.label")}
|
||||
placeholder={t("form_fields.email_secondary.placeholder")}
|
||||
description={t("form_fields.email_secondary.description")}
|
||||
icon={<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
|
||||
icon={<AtSignIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
typePreset='phone'
|
||||
className='lg:col-span-2'
|
||||
control={control}
|
||||
name='mobile_secondary'
|
||||
@ -94,6 +98,7 @@ export const CustomerContactFields = () => {
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
typePreset='phone'
|
||||
className='lg:col-span-2'
|
||||
control={control}
|
||||
name='phone_secondary'
|
||||
@ -120,16 +125,22 @@ export const CustomerContactFields = () => {
|
||||
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
|
||||
<Field className='lg:col-span-2'>
|
||||
<TextField
|
||||
typePreset='text'
|
||||
className='lg:col-span-2'
|
||||
control={control}
|
||||
name='website'
|
||||
label={t("form_fields.website.label")}
|
||||
placeholder={t("form_fields.website.placeholder")}
|
||||
description={t("form_fields.website.description")}
|
||||
icon={
|
||||
<GlobeIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
|
||||
}
|
||||
|
||||
/>
|
||||
</Field>
|
||||
<Field className='lg:col-span-2'>
|
||||
<TextField
|
||||
typePreset='phone'
|
||||
className='lg:col-span-2'
|
||||
control={control}
|
||||
name='fax'
|
||||
|
||||
@ -5,6 +5,7 @@ import { CustomerFormData } from "../../schemas";
|
||||
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
||||
import { CustomerAddressFields } from "./customer-address-fields";
|
||||
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
||||
import { CustomerContactFields } from './customer-contact-fields';
|
||||
|
||||
interface CustomerFormProps {
|
||||
formId: string;
|
||||
@ -23,7 +24,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProp
|
||||
</div>
|
||||
<div className='w-full xl:grow space-y-6'>
|
||||
<CustomerBasicInfoFields />
|
||||
<CustomerAddressFields />
|
||||
<CustomerContactFields />
|
||||
<CustomerAddressFields />
|
||||
<CustomerAdditionalConfigFields />
|
||||
</div>
|
||||
|
||||
@ -156,7 +156,7 @@ export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEd
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Guardar</Button>
|
||||
<Button onClick={handleSubmit}>Guardar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081);
|
||||
--sidebar-border: oklch(0.9173 0.0067 286.2663);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.25rem;
|
||||
--shadow-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
@ -122,7 +122,7 @@
|
||||
--sidebar-accent-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-border: oklch(1.0 0 0);
|
||||
--sidebar-ring: oklch(0.4915 0.2776 263.8724);
|
||||
--radius: 0.4rem;
|
||||
--radius: 0.25rem;
|
||||
--shadow-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user