Facturas de cliente

This commit is contained in:
David Arranz 2025-10-06 19:40:37 +02:00
parent a28e03eddd
commit ec86f74830
30 changed files with 997 additions and 420 deletions

View File

@ -20,12 +20,12 @@
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@tanstack/react-query": "^5.75.4", "@tanstack/react-query": "^5.75.4",
"ag-grid-community": "^33.3.0", "ag-grid-community": "^33.3.0",
"axios": "^1.9.0", "axios": "^1.9.0",

View File

@ -91,8 +91,8 @@ export class Tax extends ValueObject<TaxProps> {
const item = maybeItem.unwrap()!; const item = maybeItem.unwrap()!;
// Delegamos en create para reusar validación y límites // Delegamos en create para reusar validación y límites
return Tax.create({ return Tax.create({
value: item.value, value: Number(item.value),
scale: item.scale ?? Tax.DEFAULT_SCALE, scale: Number(item.scale) ?? Tax.DEFAULT_SCALE,
name: item.name, name: item.name,
code: item.code, // guardamos el code tal cual viene del catálogo code: item.code, // guardamos el code tal cual viene del catálogo
}); });

View File

@ -2,8 +2,8 @@
{ {
"name": "IVA 21%", "name": "IVA 21%",
"code": "iva_21", "code": "iva_21",
"value": 2100, "value": "2100",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "IVA general. Tipo estándar nacional.", "description": "IVA general. Tipo estándar nacional.",
"aeat_code": "01" "aeat_code": "01"
@ -11,8 +11,8 @@
{ {
"name": "IVA 18%", "name": "IVA 18%",
"code": "iva_18", "code": "iva_18",
"value": 1800, "value": "1800",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "IVA general. Tipo estándar nacional hasta finales de 2011", "description": "IVA general. Tipo estándar nacional hasta finales de 2011",
"aeat_code": null "aeat_code": null
@ -20,8 +20,8 @@
{ {
"name": "IVA 16%", "name": "IVA 16%",
"code": "iva_16", "code": "iva_16",
"value": 1600, "value": "1600",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "IVA general. Tipo estándar nacional hasta finales de 2009.", "description": "IVA general. Tipo estándar nacional hasta finales de 2009.",
"aeat_code": null "aeat_code": null
@ -29,8 +29,8 @@
{ {
"name": "IVA 10%", "name": "IVA 10%",
"code": "iva_10", "code": "iva_10",
"value": 1000, "value": "1000",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "IVA reducido para bienes y servicios específicos.", "description": "IVA reducido para bienes y servicios específicos.",
"aeat_code": "03" "aeat_code": "03"
@ -38,8 +38,8 @@
{ {
"name": "IVA 7,5%", "name": "IVA 7,5%",
"code": "iva_7_5", "code": "iva_7_5",
"value": 750, "value": "750",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Tipo especial de IVA.", "description": "Tipo especial de IVA.",
"aeat_code": null "aeat_code": null
@ -47,8 +47,8 @@
{ {
"name": "IVA 5%", "name": "IVA 5%",
"code": "iva_5", "code": "iva_5",
"value": 500, "value": "500",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Tipo reducido especial.", "description": "Tipo reducido especial.",
"aeat_code": null "aeat_code": null
@ -56,8 +56,8 @@
{ {
"name": "IVA 4%", "name": "IVA 4%",
"code": "iva_4", "code": "iva_4",
"value": 400, "value": "400",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "IVA superreducido para bienes de primera necesidad.", "description": "IVA superreducido para bienes de primera necesidad.",
"aeat_code": "02" "aeat_code": "02"
@ -65,8 +65,8 @@
{ {
"name": "IVA 2%", "name": "IVA 2%",
"code": "iva_2", "code": "iva_2",
"value": 200, "value": "200",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Tipo especial de IVA.", "description": "Tipo especial de IVA.",
"aeat_code": null "aeat_code": null
@ -74,8 +74,8 @@
{ {
"name": "IVA 0%", "name": "IVA 0%",
"code": "iva_0", "code": "iva_0",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Operaciones sujetas pero tipo cero.", "description": "Operaciones sujetas pero tipo cero.",
"aeat_code": "05" "aeat_code": "05"
@ -83,8 +83,8 @@
{ {
"name": "Exenta", "name": "Exenta",
"code": "iva_exenta", "code": "iva_exenta",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Operación exenta de IVA.", "description": "Operación exenta de IVA.",
"aeat_code": "04" "aeat_code": "04"
@ -92,8 +92,8 @@
{ {
"name": "No sujeto", "name": "No sujeto",
"code": "iva_no_sujeto", "code": "iva_no_sujeto",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Operación no sujeta a IVA.", "description": "Operación no sujeta a IVA.",
"aeat_code": "06" "aeat_code": "06"
@ -101,8 +101,8 @@
{ {
"name": "IVA Intracomunitario Bienes", "name": "IVA Intracomunitario Bienes",
"code": "iva_intracomunitario_bienes", "code": "iva_intracomunitario_bienes",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Entrega intracomunitaria de bienes, exenta de IVA.", "description": "Entrega intracomunitaria de bienes, exenta de IVA.",
"aeat_code": "E5" "aeat_code": "E5"
@ -110,8 +110,8 @@
{ {
"name": "IVA Intracomunitario Servicio", "name": "IVA Intracomunitario Servicio",
"code": "iva_intracomunitario_servicio", "code": "iva_intracomunitario_servicio",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Prestación intracomunitaria de servicios, exenta.", "description": "Prestación intracomunitaria de servicios, exenta.",
"aeat_code": "E6" "aeat_code": "E6"
@ -119,8 +119,8 @@
{ {
"name": "Exportación", "name": "Exportación",
"code": "iva_exportacion", "code": "iva_exportacion",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Exportaciones exentas de IVA.", "description": "Exportaciones exentas de IVA.",
"aeat_code": "E2" "aeat_code": "E2"
@ -128,8 +128,8 @@
{ {
"name": "Inv. Suj. Pasivo", "name": "Inv. Suj. Pasivo",
"code": "iva_inversion_sujeto_pasivo", "code": "iva_inversion_sujeto_pasivo",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IVA", "group": "IVA",
"description": "Inversión del sujeto pasivo.", "description": "Inversión del sujeto pasivo.",
"aeat_code": "09" "aeat_code": "09"
@ -138,8 +138,8 @@
{ {
"name": "Retención 35%", "name": "Retención 35%",
"code": "retencion_35", "code": "retencion_35",
"value": 3500, "value": "3500",
"scale": 2, "scale": "2",
"group": "Retención", "group": "Retención",
"description": "Retención profesional o fiscal tipo máximo.", "description": "Retención profesional o fiscal tipo máximo.",
"aeat_code": null "aeat_code": null
@ -147,8 +147,8 @@
{ {
"name": "Retención 19%", "name": "Retención 19%",
"code": "retencion_19", "code": "retencion_19",
"value": 1900, "value": "1900",
"scale": 2, "scale": "2",
"group": "Retención", "group": "Retención",
"description": "Retención IRPF general.", "description": "Retención IRPF general.",
"aeat_code": "R1" "aeat_code": "R1"
@ -156,8 +156,8 @@
{ {
"name": "Retención 15%", "name": "Retención 15%",
"code": "retencion_15", "code": "retencion_15",
"value": 1500, "value": "1500",
"scale": 2, "scale": "2",
"group": "Retención", "group": "Retención",
"description": "Retención para autónomos y profesionales.", "description": "Retención para autónomos y profesionales.",
"aeat_code": "R2" "aeat_code": "R2"
@ -165,8 +165,8 @@
{ {
"name": "Retención 7%", "name": "Retención 7%",
"code": "retencion_7", "code": "retencion_7",
"value": 700, "value": "700",
"scale": 2, "scale": "2",
"group": "Retención", "group": "Retención",
"description": "Retención para nuevos autónomos.", "description": "Retención para nuevos autónomos.",
"aeat_code": null "aeat_code": null
@ -174,8 +174,8 @@
{ {
"name": "Retención 2%", "name": "Retención 2%",
"code": "retencion_2", "code": "retencion_2",
"value": 200, "value": "200",
"scale": 2, "scale": "2",
"group": "Retención", "group": "Retención",
"description": "Retención sobre arrendamientos de inmuebles urbanos.", "description": "Retención sobre arrendamientos de inmuebles urbanos.",
"aeat_code": "R3" "aeat_code": "R3"
@ -184,8 +184,8 @@
{ {
"name": "REC 5,2%", "name": "REC 5,2%",
"code": "rec_5_2", "code": "rec_5_2",
"value": 520, "value": "520",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo general para IVA 21%.", "description": "Recargo general para IVA 21%.",
"aeat_code": "51" "aeat_code": "51"
@ -193,8 +193,8 @@
{ {
"name": "REC 1,75%", "name": "REC 1,75%",
"code": "rec_1_75", "code": "rec_1_75",
"value": 175, "value": "175",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo para IVA 10%.", "description": "Recargo para IVA 10%.",
"aeat_code": "52" "aeat_code": "52"
@ -202,8 +202,8 @@
{ {
"name": "REC 1,4%", "name": "REC 1,4%",
"code": "rec_1_4", "code": "rec_1_4",
"value": 140, "value": "140",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo para IVA 5%.", "description": "Recargo para IVA 5%.",
"aeat_code": null "aeat_code": null
@ -211,8 +211,8 @@
{ {
"name": "REC 1%", "name": "REC 1%",
"code": "rec_1", "code": "rec_1",
"value": 100, "value": "100",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo especial.", "description": "Recargo especial.",
"aeat_code": null "aeat_code": null
@ -220,8 +220,8 @@
{ {
"name": "REC 0,62%", "name": "REC 0,62%",
"code": "rec_0_62", "code": "rec_0_62",
"value": 62, "value": "62",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo para IVA reducido especial.", "description": "Recargo para IVA reducido especial.",
"aeat_code": null "aeat_code": null
@ -229,8 +229,8 @@
{ {
"name": "REC 0,5%", "name": "REC 0,5%",
"code": "rec_0_5", "code": "rec_0_5",
"value": 50, "value": "50",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo especial.", "description": "Recargo especial.",
"aeat_code": null "aeat_code": null
@ -238,8 +238,8 @@
{ {
"name": "REC 0,26%", "name": "REC 0,26%",
"code": "rec_0_26", "code": "rec_0_26",
"value": 26, "value": "26",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Recargo mínimo.", "description": "Recargo mínimo.",
"aeat_code": null "aeat_code": null
@ -247,8 +247,8 @@
{ {
"name": "REC 0%", "name": "REC 0%",
"code": "rec_0", "code": "rec_0",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "Recargo de equivalencia", "group": "Recargo de equivalencia",
"description": "Sin recargo.", "description": "Sin recargo.",
"aeat_code": null "aeat_code": null
@ -257,8 +257,8 @@
{ {
"name": "IGIC 7%", "name": "IGIC 7%",
"code": "igic_7", "code": "igic_7",
"value": 700, "value": "700",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo general IGIC Canarias.", "description": "Tipo general IGIC Canarias.",
"aeat_code": "10" "aeat_code": "10"
@ -266,8 +266,8 @@
{ {
"name": "IGIC 3%", "name": "IGIC 3%",
"code": "igic_3", "code": "igic_3",
"value": 300, "value": "300",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo reducido IGIC Canarias.", "description": "Tipo reducido IGIC Canarias.",
"aeat_code": "11" "aeat_code": "11"
@ -275,8 +275,8 @@
{ {
"name": "IGIC 0%", "name": "IGIC 0%",
"code": "igic_0", "code": "igic_0",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Operación exenta IGIC.", "description": "Operación exenta IGIC.",
"aeat_code": "12" "aeat_code": "12"
@ -284,8 +284,8 @@
{ {
"name": "IGIC 9,5%", "name": "IGIC 9,5%",
"code": "igic_9_5", "code": "igic_9_5",
"value": 950, "value": "950",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo incrementado IGIC.", "description": "Tipo incrementado IGIC.",
"aeat_code": "13" "aeat_code": "13"
@ -293,8 +293,8 @@
{ {
"name": "IGIC 13,5%", "name": "IGIC 13,5%",
"code": "igic_13_5", "code": "igic_13_5",
"value": 1350, "value": "1350",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo incrementado especial IGIC.", "description": "Tipo incrementado especial IGIC.",
"aeat_code": "14" "aeat_code": "14"
@ -302,8 +302,8 @@
{ {
"name": "IGIC 20%", "name": "IGIC 20%",
"code": "igic_20", "code": "igic_20",
"value": 2000, "value": "2000",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo incrementado IGIC.", "description": "Tipo incrementado IGIC.",
"aeat_code": "15" "aeat_code": "15"
@ -311,8 +311,8 @@
{ {
"name": "IGIC 1%", "name": "IGIC 1%",
"code": "igic_1", "code": "igic_1",
"value": 100, "value": "100",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo reducido IGIC.", "description": "Tipo reducido IGIC.",
"aeat_code": "16" "aeat_code": "16"
@ -320,8 +320,8 @@
{ {
"name": "IGIC 2,75%", "name": "IGIC 2,75%",
"code": "igic_2_75", "code": "igic_2_75",
"value": 275, "value": "275",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Tipo especial IGIC.", "description": "Tipo especial IGIC.",
"aeat_code": null "aeat_code": null
@ -329,8 +329,8 @@
{ {
"name": "IGIC Exento", "name": "IGIC Exento",
"code": "igic_exento", "code": "igic_exento",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IGIC", "group": "IGIC",
"description": "Operación exenta de IGIC.", "description": "Operación exenta de IGIC.",
"aeat_code": "12" "aeat_code": "12"
@ -339,8 +339,8 @@
{ {
"name": "IPSI 10%", "name": "IPSI 10%",
"code": "ipsi_10", "code": "ipsi_10",
"value": 1000, "value": "1000",
"scale": 2, "scale": "2",
"group": "IPSI", "group": "IPSI",
"description": "Tipo general IPSI Ceuta/Melilla.", "description": "Tipo general IPSI Ceuta/Melilla.",
"aeat_code": null "aeat_code": null
@ -348,8 +348,8 @@
{ {
"name": "IPSI 4%", "name": "IPSI 4%",
"code": "ipsi_4", "code": "ipsi_4",
"value": 400, "value": "400",
"scale": 2, "scale": "2",
"group": "IPSI", "group": "IPSI",
"description": "Tipo reducido IPSI.", "description": "Tipo reducido IPSI.",
"aeat_code": null "aeat_code": null
@ -357,8 +357,8 @@
{ {
"name": "IPSI 0,5%", "name": "IPSI 0,5%",
"code": "ipsi_0_5", "code": "ipsi_0_5",
"value": 50, "value": "50",
"scale": 2, "scale": "2",
"group": "IPSI", "group": "IPSI",
"description": "Tipo superreducido IPSI.", "description": "Tipo superreducido IPSI.",
"aeat_code": null "aeat_code": null
@ -366,8 +366,8 @@
{ {
"name": "IPSI Exento", "name": "IPSI Exento",
"code": "ipsi_exento", "code": "ipsi_exento",
"value": 0, "value": "0",
"scale": 2, "scale": "2",
"group": "IPSI", "group": "IPSI",
"description": "Operación exenta de IPSI.", "description": "Operación exenta de IPSI.",
"aeat_code": null "aeat_code": null

View File

@ -3,8 +3,8 @@
export type TaxItemType = { export type TaxItemType = {
name: string; // p.ej. "IVA 21%" name: string; // p.ej. "IVA 21%"
code: string; // p.ej. "iva_21" code: string; // p.ej. "iva_21"
value: number; // porcentaje * 10^scale (21% => 2100) value: string; // porcentaje * 10^scale (21% => 2100)
scale: number; // decimales de 'value' (normalmente 2) scale: string; // decimales de 'value' (normalmente 2)
group: string; // p.ej. "IVA", "IGIC", "IPSI", "Retención" group: string; // p.ej. "IVA", "IGIC", "IPSI", "Retención"
description?: string; // opcional description?: string; // opcional
aeat_code?: string | null; // opcional aeat_code?: string | null; // opcional

View File

@ -0,0 +1 @@
export * from "./money-utils";

View 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);
}

View File

@ -1,4 +1,5 @@
export * from "./catalogs"; export * from "./catalogs";
export * from "./dto"; export * from "./dto";
export * from "./helpers";
export * from "./schemas"; export * from "./schemas";
export * from "./types"; export * from "./types";

View File

@ -1,6 +1,9 @@
export * from "./use-datasource"; export * from "./use-datasource";
export * from "./use-hook-form"; export * from "./use-hook-form";
export * from "./use-money";
export * from "./use-pagination"; export * from "./use-pagination";
export * from "./use-percentage";
export * from "./use-quantity";
export * from "./use-query-key"; export * from "./use-query-key";
export * from "./use-toggle"; export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";

View 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]);
}

View 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 }), []);
}

View 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 }), []);
}

View File

@ -1,3 +1,2 @@
export * from "./date-func"; export * from "./date-func";
export * from "./form-utils"; export * from "./form-utils";
export * from "./money-funcs";

View File

@ -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();
};

View File

@ -11,7 +11,8 @@ import { useCallback, useMemo, useState } from "react";
import { MoneyDTO } from "@erp/core"; 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 { ErrorOverlay } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
@ -25,150 +26,185 @@ import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
export const CustomerInvoicesListGrid = () => { export const CustomerInvoicesListGrid = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { format } = useMoney();
const { const {
data: customersData, data: invoices,
isLoading: isLoadingCustomerInvoices, isLoading,
isError: isLoadError, isError,
error: loadError, error,
} = useCustomerInvoicesQuery({ } = useCustomerInvoicesQuery({
pagination: { pagination: { pageSize: 999 },
pageSize: 999,
},
}); });
// Column Definitions: Defines & controls grid columns. // Definición de columnas
const [colDefs] = useState<ColDef[]>([ const [colDefs] = useState<ColDef[]>([
{ {
field: "status", field: "status",
filter: true,
headerName: t("pages.list.grid_columns.status"), headerName: t("pages.list.grid_columns.status"),
cellRenderer: (params: ValueFormatterParams) => { cellRenderer: (params: ValueFormatterParams) => (
return <CustomerInvoiceStatusBadge status={params.value} />; <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", field: "invoice_date",
headerName: t("pages.list.grid_columns.invoice_date"), headerName: t("pages.list.grid_columns.invoice_date"),
valueFormatter: (params: ValueFormatterParams) => { valueFormatter: (p: ValueFormatterParams) => formatDate(p.value),
return formatDate(params.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", field: "recipient.postal_code",
headerName: t("pages.list.grid_columns.recipient_postal_code"), headerName: t("pages.list.grid_columns.recipient_postal_code"),
minWidth: 100,
}, },
{ {
field: "taxable_amount", field: "taxable_amount",
headerName: t("pages.list.grid_columns.taxable_amount"), headerName: t("pages.list.grid_columns.taxable_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => { valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value; const raw: MoneyDTO | null = params.value;
return formatMoney(rawValue); return raw ? format(raw) : "—";
}, },
cellClass: "tabular-nums",
minWidth: 130,
}, },
{ {
field: "taxes_amount", field: "taxes_amount",
headerName: t("pages.list.grid_columns.taxable_amount"), headerName: t("pages.list.grid_columns.taxes_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => { valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value; const raw: MoneyDTO | null = params.value;
return formatMoney(rawValue); return raw ? format(raw) : "—";
}, },
cellClass: "tabular-nums",
minWidth: 130,
}, },
{ {
field: "total_amount", field: "total_amount",
headerName: t("pages.list.grid_columns.total_amount"), headerName: t("pages.list.grid_columns.total_amount"),
type: "rightAligned",
valueFormatter: (params: ValueFormatterParams) => { valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value; const raw: MoneyDTO | null = params.value;
return formatMoney(rawValue); return raw ? format(raw) : "—";
}, },
cellClass: "tabular-nums font-semibold",
minWidth: 140,
}, },
{ {
colId: "actions", colId: "actions",
headerName: t("pages.list.grid_columns.actions", "Actions"), headerName: t("pages.list.grid_columns.actions", "Acciones"),
cellRenderer: (params: ValueFormatterParams) => { cellRenderer: (params: ValueFormatterParams) => {
const { data } = params; const id = params.data?.id;
if (!id) return null;
return ( return (
<Button <Button
variant='secondary' variant="secondary"
size='icon' size="icon"
className='size-8' className="size-8"
onClick={() => { aria-label={t("pages.list.open_invoice", "Abrir factura")}
navigate(`/customer-invoices/${data.id}/edit`); onClick={() => navigate(`/customer-invoices/${id}/edit`)}
}}
> >
<ChevronRightIcon /> <ChevronRightIcon className="h-4 w-4" />
</Button> </Button>
); );
}, },
minWidth: 80,
maxWidth: 80,
pinned: "right",
}, },
]); ]);
// Navegación centralizada (click/teclado) // Navegación accesible (click o teclado)
const goToRow = useCallback( const goToRow = useCallback(
(id: string, newTab = false) => { (id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`; const url = `/customer-invoices/${id}/edit`;
if (newTab) { newTab
window.open(url, "_blank", "noopener,noreferrer"); ? window.open(url, "_blank", "noopener,noreferrer")
} else { : navigate(url);
navigate(url);
}
}, },
[navigate] [navigate]
); );
const onRowClicked = useCallback( const onRowClicked = useCallback(
(e: RowClickedEvent<unknown>) => { (e: RowClickedEvent<any>) => {
if (!e.data) return; if (!e.data) return;
// Soporta Ctrl/Cmd click para nueva pestaña const newTab =
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey); e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab); goToRow(e.data.id, newTab);
}, },
[goToRow] [goToRow]
); );
const onCellKeyDown = useCallback( const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<unknown>) => { (e: CellKeyDownEvent<any>) => {
if (!e.data) return; 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 === " ") { if (key === "Enter" || key === " ") {
e.event.preventDefault(); ev.preventDefault();
goToRow(e.data.id); goToRow(e.data.id);
} }
// Ctrl/Cmd+Enter abre en nueva pestaña if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
if ((e.event.ctrlKey || e.event.metaKey) && key === "Enter") { ev.preventDefault();
e.event.preventDefault();
goToRow(e.data.id, true); goToRow(e.data.id, true);
} }
}, },
[goToRow] [goToRow]
); );
// Estrategia de autoajuste de columnas
const autoSizeStrategy = useMemo< const autoSizeStrategy = useMemo<
| SizeColumnsToFitGridStrategy | SizeColumnsToFitGridStrategy
| SizeColumnsToFitProvidedWidthStrategy | SizeColumnsToFitProvidedWidthStrategy
| SizeColumnsToContentStrategy | SizeColumnsToContentStrategy
>(() => { >(
return { () => ({
type: "fitGridWidth", type: "fitGridWidth",
defaultMinWidth: 100, 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( const gridOptions: GridOptions = useMemo(
() => ({ () => ({
columnDefs: colDefs, columnDefs: colDefs,
autoSizeStrategy: autoSizeStrategy, autoSizeStrategy,
defaultColDef: { defaultColDef: {
editable: false, editable: false,
flex: 1, flex: 1,
@ -177,52 +213,41 @@ export const CustomerInvoicesListGrid = () => {
resizable: true, resizable: true,
}, },
pagination: true, pagination: true,
paginationPageSize: 15, paginationPageSize: 20,
paginationPageSizeSelector: [10, 15, 20, 30, 50], paginationPageSizeSelector: [10, 20, 30, 50],
localeText: AG_GRID_LOCALE_ES, localeText: AG_GRID_LOCALE_ES,
// Evita conflictos con selección si la usas
suppressRowClickSelection: true, suppressRowClickSelection: true,
// Clase visual de fila clickeable
getRowClass: () => "clickable-row", getRowClass: () => "clickable-row",
// Accesibilidad con teclado
onCellKeyDown, onCellKeyDown,
// Click en cualquier parte de la fila
onRowClicked, onRowClicked,
// IDs estables (opcional pero recomendado) getRowId: (p) => p.data.id,
getRowId: (params) => params.data.id,
}), }),
[autoSizeStrategy, colDefs] [autoSizeStrategy, colDefs, onCellKeyDown, onRowClicked]
); );
if (isLoadError) { // Error al cargar
if (isError) {
return ( return (
<>
<ErrorOverlay <ErrorOverlay
errorMessage={ errorMessage={
(loadError as Error)?.message ?? (error as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.") t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
} }
/> />
</>
); );
} }
// Container: Defines the grid's theme & dimensions. // Render principal
return ( return (
<div <section
className='ag-theme-alpine' className="ag-theme-alpine ag-theme-shadcn w-full h-full"
style={{ aria-label={t("pages.list.aria_label", "Listado de facturas de cliente")}
height: "100%",
width: "100%",
}}
> >
<AgGridReact <AgGridReact
rowData={customersData?.items ?? []} rowData={invoices?.items ?? []}
loading={isLoadingCustomerInvoices} loading={isLoading}
{...gridOptions} {...gridOptions}
/> />
</div> </section>
); );
}; };

View File

@ -2,79 +2,56 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-u
import { Grid3X3Icon, Package, PlusIcon, TableIcon } from "lucide-react"; import { Grid3X3Icon, Package, PlusIcon, TableIcon } from "lucide-react";
import { useState } from "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 { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas"; import { CustomerInvoiceItemFormData } from "../../schemas";
import { BlocksView, TableView } from "./items"; import { BlocksView, TableView } from "./items";
export const InvoiceItems = () => { export const InvoiceItems = () => {
const [viewMode, setViewMode] = useState<"blocks" | "table">("table"); const [viewMode, setViewMode] = useState<"blocks" | "table">("table");
const { t } = useTranslation(); const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>(); const calculateItemAmounts = useCalculateItemAmounts();
const { control, setValue, watch } = useFormContext();
const { fields, append, remove, insert, move } = useFieldArray({
const invoice = useWatch({ control });
const { fields: items, ...fieldActions } = useFieldArray({
control, control,
name: "items", name: "items",
}); });
const addItem = () => { const items = watch("items") as CustomerInvoiceItemFormData[];
/*const newItem = {
position: invoice.items ? invoice.items.length + 1 : 0, const updateItem = (index: number, patch: Partial<CustomerInvoiceItemFormData>) => {
description: "", const updated = { ...items[index], ...patch };
quantity: 1, const recalculated = calculateItemAmounts(updated as CustomerInvoiceItemFormData);
unit_amount: 0, setValue(`${"items"}.${index}`, recalculated, { shouldDirty: true });
taxes: [],
subtotal_amount: 0,
discount_percentage: 0,
discount_amount: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
}; };
setInvoice({ const duplicateItem = (index: number) => {
...invoice, const copy = structuredClone(items[index]);
items: [...invoice.items, newItem], insert(index + 1, copy);
});*/
}; };
const removeItem = (index: number) => { const removeItem = (index: number) => {
/*const newItems = invoice.items.filter((_: any, i: number) => i !== index); remove(index)
setInvoice({
...invoice,
items: newItems,
});*/
}; };
const updateItem = (index: number, field: string, value: any) => {
/*const newItems = [...invoice.items];
newItems[index] = { ...newItems[index], [field]: value };
// Recalculate amounts const addNewItem = () => {
const item = newItems[index]; const newItem: CustomerInvoiceItemFormData = {
item.subtotal_amount = item.quantity * item.unit_amount; isNonValued: "false",
item.discount_amount = (item.subtotal_amount * item.discount_percentage) / 100; description: "",
item.taxable_amount = item.subtotal_amount - item.discount_amount; quantity: { value: "0", scale: "2" },
item.taxes_amount = item.taxable_amount * 0.21; // Mock 21% tax unit_amount: { value: "0", scale: "2", currency_code: "EUR" },
item.total_amount = item.taxable_amount + item.taxes_amount; discount_percentage: { value: "0", scale: "2" },
discount_amount: { value: "0", scale: "2", currency_code: "EUR" },
setInvoice({ taxable_amount: { value: "0", scale: "2", currency_code: "EUR" },
...invoice, taxes_amount: { value: "0", scale: "2", currency_code: "EUR" },
items: newItems, 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));
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(amount);
}; };
return ( return (
@ -106,18 +83,28 @@ export const InvoiceItems = () => {
Tabla Tabla
</Button> </Button>
</div> </div>
<Button onClick={addItem} size='sm'> <Button onClick={addNewItem} size='sm'>
<PlusIcon className='h-4 w-4 mr-2' /> <PlusIcon className='h-4 w-4 mr-2' />
Añadir Línea Añadir Línea
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='overflow-auto'>
{viewMode === "blocks" ? ( {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> </CardContent>
</Card> </Card>

View File

@ -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>
</>
)
}

View File

@ -1,141 +1,301 @@
import { import {
Badge,
Button, Button,
Input, Input,
Select, Table,
SelectContent, TableBody,
SelectItem, TableCell,
SelectTrigger, TableHead,
SelectValue, TableHeader,
TableRow,
Textarea,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components"; } 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"; import { CustomItemViewProps } from "./types";
export interface TableViewProps extends CustomItemViewProps {} export interface TableViewProps extends CustomItemViewProps { }
const formatCurrency = (amount: number) => { export const TableView = ({ items, actions }: TableViewProps) => {
return new Intl.NumberFormat("es-ES", { const { format } = useMoney();
style: "currency", const calculateItemAmounts = useCalculateItemAmounts();
currency: "EUR", const [lines, setLines] = useState<CustomerInvoiceItemFormData[]>(items);
minimumFractionDigits: 2,
maximumFractionDigits: 4, useEffect(() => {
}).format(amount); 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 ( return (
<div className='overflow-x-auto'> <div className="space-y-4">
<table className='w-full border-collapse'> <div className="rounded-lg border border-border">
<thead> <Table className="min-w-full">
<tr className='border-b bg-muted/30'> <TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<th className='text-left p-3 text-sm font-medium'>#</th> <TableRow className="bg-muted/30 text-xs text-muted-foreground">
<th className='text-left p-3 text-sm font-medium min-w-[200px]'>Descripción</th> <TableHead className="w-10 text-center">#</TableHead>
<th className='text-left p-3 text-sm font-medium w-24'>Cantidad</th> <TableHead>Descripción</TableHead>
<th className='text-left p-3 text-sm font-medium w-32'>Precio Unit.</th> <TableHead className="text-right w-24">Cantidad</TableHead>
<th className='text-left p-3 text-sm font-medium w-24'>% Desc.</th> <TableHead className="text-right w-32">Precio Unit.</TableHead>
<th className='text-left p-3 text-sm font-medium w-32'>Impuestos</th> <TableHead className="text-right w-24">% Desc.</TableHead>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Subtotal</th> <TableHead className="text-right w-32">Total</TableHead>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Descuento</th> <TableHead className="w-44 text-center">Acciones</TableHead>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Base Imp.</th> </TableRow>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Impuestos</th> </TableHeader>
<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> <TableBody>
</tr> {lines.map((item, i) => (
</thead> <TableRow key={`item-${i}`} className="text-sm hover:bg-muted/40">
<tbody> {/* ÍNDICE */}
{items.map((item: any, index: number) => ( <TableCell className="text-center text-muted-foreground font-mono align-text-top">
<tr key={`item-${String(index)}`} className='border-b hover:bg-muted/20'> {i + 1}
<td className='p-3'> </TableCell>
<Badge variant='outline' className='text-xs'>
{item.position} {/* DESCRIPCIÓN */}
</Badge>
</td> <TableCell className="align-top">
<td className='p-3'> <Textarea
<Input
value={item.description} value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)} onChange={(e) => updateItem(i, { description: e.target.value })}
placeholder='Descripción...' placeholder="Descripción del producto o servicio…"
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0' 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"
/> />
</td>
<td className='p-3'>
</TableCell>
{/* CANTIDAD */}
<TableCell className="text-right">
<Input <Input
type='number' type="number"
step='0.01' inputMode="decimal"
value={item.quantity} 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) => onChange={(e) =>
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0) updateItem(i, {
quantity: {
value: (
Number(e.target.value) * 10 ** Number(item.quantity.scale)
).toString(),
scale: item.quantity.scale,
},
})
} }
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' aria-label={`Cantidad línea ${i + 1}`}
/> />
</td> </TableCell>
<td className='p-3'>
{/* PRECIO UNITARIO */}
<TableCell className="text-right">
<Input <Input
type='number' type="number"
step='0.0001' inputMode="decimal"
value={item.unit_amount} 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) => onChange={(e) =>
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0) updateItem(i, {
unit_amount: {
...item.unit_amount,
value: (
Number(e.target.value) * 10 ** Number(item.unit_amount.scale)
).toString(),
},
})
} }
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' aria-label={`Precio unitario línea ${i + 1}`}
/> />
</td> </TableCell>
<td className='p-3'>
{/* DESCUENTO */}
<TableCell className="text-right">
<Input <Input
type='number' type="number"
step='0.0001' inputMode="decimal"
value={item.discount_percentage} className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
onChange={(e) => value={
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0) Number(item.discount_percentage.value) /
10 ** Number(item.discount_percentage.scale)
} }
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right' 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}`}
/> />
</td> </TableCell>
<td className='p-3'>
<Select> {/* TOTAL */}
<SelectTrigger className='border-0 bg-transparent p-0 h-auto focus:ring-0'> <TableCell className="text-right font-mono">
<SelectValue placeholder='IVA 21%' /> <HoverCardTotalsSummary item={item}>
</SelectTrigger> <span className="cursor-help hover:text-primary transition-colors">
<SelectContent> {format(item.total_amount)}
<SelectItem value='iva21'>IVA 21%</SelectItem> </span>
<SelectItem value='iva10'>IVA 10%</SelectItem> </HoverCardTotalsSummary>
<SelectItem value='iva4'>IVA 4%</SelectItem> </TableCell>
<SelectItem value='exento'>Exento</SelectItem>
</SelectContent> {/* ACCIONES */}
</Select> <TableCell className="text-center">
</td> <div className="flex items-center justify-center gap-1">
<td className='sr-only p-3 text-right text-sm font-medium'> <TooltipProvider>
{formatCurrency(item.subtotal_amount)} <Tooltip>
</td> <TooltipTrigger asChild>
<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 <Button
variant='ghost' variant="ghost"
size='sm' size="icon"
onClick={() => removeItem(index)} onClick={() => moveItem(i, "up")}
className='text-destructive hover:text-destructive h-8 w-8 p-0' disabled={i === 0}
className="h-7 w-7"
> >
<Trash2Icon className='h-4 w-4' /> <ChevronUpIcon className="size-3.5" />
</Button> </Button>
)} </TooltipTrigger>
</td> <TooltipContent>Mover arriba</TooltipContent>
</tr> </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>
))} ))}
</tbody> </TableBody>
</table> </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> </div>
); );
}; }

View File

@ -1,5 +1,6 @@
export interface CustomItemViewProps { export interface CustomItemViewProps {
items: any; items: CustomerInvoiceItemFormData[];
updateItem: (index: number, field: string, value: any) => void; onItemsChange: (items: InvoiceItem[]) => void;
removeItem: (index: number) => void; updateItem?: (index: number, field: string, value: any) => void;
removeItem?: (index: number) => void;
} }

View File

@ -1,3 +1,4 @@
import { cn } from '@repo/shadcn-ui/lib/utils';
// features/common/components/page-header.tsx // features/common/components/page-header.tsx
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge"; import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
@ -13,11 +14,13 @@ interface PageHeaderProps {
status?: string; status?: string;
/** Contenido del lado derecho (botones, menús, etc.) */ /** Contenido del lado derecho (botones, menús, etc.) */
rightSlot?: ReactNode; rightSlot?: ReactNode;
className?: string;
} }
export function PageHeader({ icon, title, description, status, rightSlot }: PageHeaderProps) { export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
return ( 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='mx-auto w-full px-6 pt-2 pb-8'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
{/* Lado izquierdo */} {/* Lado izquierdo */}

View File

@ -1,4 +1,6 @@
export * from "./use-calculate-item-amounts";
export * from "./use-create-customer-invoice-mutation"; export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoice-item-summary";
export * from "./use-customer-invoice-query"; export * from "./use-customer-invoice-query";
export * from "./use-customer-invoices-context"; export * from "./use-customer-invoices-context";
export * from "./use-customer-invoices-query"; export * from "./use-customer-invoices-query";

View File

@ -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,
};
};
}

View File

@ -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]);
}

View File

@ -132,7 +132,7 @@ export const CustomerInvoiceUpdatePage = () => {
return ( return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader> <AppHeader className="sticky">
<AppBreadcrumb /> <AppBreadcrumb />
<PageHeader <PageHeader
status={invoiceData.status} status={invoiceData.status}

View File

@ -17,6 +17,8 @@ export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchem
// Tipos (derivados de Zod o DTOs del backend) // Tipos (derivados de Zod o DTOs del backend)
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>; 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 CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear
export type CustomerInvoiceUpdateInput = z.infer<typeof CustomerInvoiceUpdateSchema>; // Cuerpo para actualizar export type CustomerInvoiceUpdateInput = z.infer<typeof CustomerInvoiceUpdateSchema>; // Cuerpo para actualizar

View File

@ -1,5 +1,32 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4"; 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({ export const CustomerInvoiceFormSchema = z.object({
invoice_number: z.string().optional(), invoice_number: z.string().optional(),
status: z.string(), status: z.string(),
@ -13,7 +40,7 @@ export const CustomerInvoiceFormSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
/*language_code: z language_code: z
.string({ .string({
error: "El idioma es obligatorio", error: "El idioma es obligatorio",
}) })
@ -29,7 +56,7 @@ export const CustomerInvoiceFormSchema = z.object({
.toUpperCase() // asegura mayúsculas .toUpperCase() // asegura mayúsculas
.default("EUR"), .default("EUR"),
taxes: z /*taxes: z
.array( .array(
z.object({ z.object({
tax_code: z.string(), tax_code: z.string(),
@ -38,16 +65,9 @@ export const CustomerInvoiceFormSchema = z.object({
}) })
) )
.optional(), .optional(),
*/
items: z items: z.array(CustomerInvoiceItemFormSchema).optional(),
.array(
z.object({
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
tax_codes: z.array(z.string()).default([]),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema, discount_percentage: PercentageSchema,
@ -55,19 +75,10 @@ export const CustomerInvoiceFormSchema = z.object({
taxable_amount: MoneySchema, taxable_amount: MoneySchema,
taxes_amount: MoneySchema, taxes_amount: MoneySchema,
total_amount: MoneySchema, total_amount: MoneySchema,
})
)
.optional(),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,*/
}); });
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>; export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
export type CustomerInvoiceItemFormData = z.infer<typeof CustomerInvoiceItemFormSchema>;
export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = { export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
invoice_number: "", invoice_number: "",
@ -83,7 +94,7 @@ export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
language_code: "es", language_code: "es",
currency_code: "EUR", currency_code: "EUR",
taxes: [], //taxes: [],
items: [], items: [],

View File

@ -22,7 +22,7 @@ interface CustomerModalSelectorProps {
initialCustomer?: CustomerSummary; initialCustomer?: CustomerSummary;
disabled?: boolean; disabled?: boolean;
readOnly?: boolean; readOnly?: boolean;
className: string; className?: string;
} }
export const CustomerModalSelector = ({ export const CustomerModalSelector = ({

View File

@ -13,7 +13,7 @@ import {
Separator, Separator,
} from "@repo/shadcn-ui/components"; } 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 { useState } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
@ -35,13 +35,14 @@ export const CustomerContactFields = () => {
label={t("form_fields.email_primary.label")} label={t("form_fields.email_primary.label")}
placeholder={t("form_fields.email_primary.placeholder")} placeholder={t("form_fields.email_primary.placeholder")}
description={t("form_fields.email_primary.description")} 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' typePreset='email'
required required
/> />
<TextField <TextField
className='lg:col-span-2' className='lg:col-span-2'
typePreset='phone'
control={control} control={control}
name='mobile_primary' name='mobile_primary'
label={t("form_fields.mobile_primary.label")} label={t("form_fields.mobile_primary.label")}
@ -56,6 +57,7 @@ export const CustomerContactFields = () => {
/> />
<TextField <TextField
typePreset='phone'
className='lg:col-span-2' className='lg:col-span-2'
control={control} control={control}
name='phone_primary' name='phone_primary'
@ -70,16 +72,18 @@ export const CustomerContactFields = () => {
<Separator className='mt-6' /> <Separator className='mt-6' />
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'> <FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<TextField <TextField
typePreset='email'
className='lg:col-span-2 lg:col-start-1' className='lg:col-span-2 lg:col-start-1'
control={control} control={control}
name='email_secondary' name='email_secondary'
label={t("form_fields.email_secondary.label")} label={t("form_fields.email_secondary.label")}
placeholder={t("form_fields.email_secondary.placeholder")} placeholder={t("form_fields.email_secondary.placeholder")}
description={t("form_fields.email_secondary.description")} 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 <TextField
typePreset='phone'
className='lg:col-span-2' className='lg:col-span-2'
control={control} control={control}
name='mobile_secondary' name='mobile_secondary'
@ -94,6 +98,7 @@ export const CustomerContactFields = () => {
} }
/> />
<TextField <TextField
typePreset='phone'
className='lg:col-span-2' className='lg:col-span-2'
control={control} control={control}
name='phone_secondary' name='phone_secondary'
@ -120,16 +125,22 @@ export const CustomerContactFields = () => {
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'> <FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<Field className='lg:col-span-2'> <Field className='lg:col-span-2'>
<TextField <TextField
typePreset='text'
className='lg:col-span-2' className='lg:col-span-2'
control={control} control={control}
name='website' name='website'
label={t("form_fields.website.label")} label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")} placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")} description={t("form_fields.website.description")}
icon={
<GlobeIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/> />
</Field> </Field>
<Field className='lg:col-span-2'> <Field className='lg:col-span-2'>
<TextField <TextField
typePreset='phone'
className='lg:col-span-2' className='lg:col-span-2'
control={control} control={control}
name='fax' name='fax'

View File

@ -5,6 +5,7 @@ import { CustomerFormData } from "../../schemas";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields"; import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from './customer-contact-fields';
interface CustomerFormProps { interface CustomerFormProps {
formId: string; formId: string;
@ -23,7 +24,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProp
</div> </div>
<div className='w-full xl:grow space-y-6'> <div className='w-full xl:grow space-y-6'>
<CustomerBasicInfoFields /> <CustomerBasicInfoFields />
<CustomerAddressFields /> <CustomerContactFields />
<CustomerAddressFields /> <CustomerAddressFields />
<CustomerAdditionalConfigFields /> <CustomerAdditionalConfigFields />
</div> </div>

View File

@ -156,7 +156,7 @@ export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEd
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleSave}>Guardar</Button> <Button onClick={handleSubmit}>Guardar</Button>
</div> </div>
</div> </div>
</Tabs> </Tabs>

View File

@ -76,7 +76,7 @@
--sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081); --sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081);
--sidebar-border: oklch(0.9173 0.0067 286.2663); --sidebar-border: oklch(0.9173 0.0067 286.2663);
--sidebar-ring: oklch(0.623 0.214 259.815); --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-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 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); --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-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(1.0 0 0); --sidebar-border: oklch(1.0 0 0);
--sidebar-ring: oklch(0.4915 0.2776 263.8724); --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-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 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); --shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);