Facturas de cliente
This commit is contained in:
parent
a28e03eddd
commit
ec86f74830
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 "./catalogs";
|
||||||
export * from "./dto";
|
export * from "./dto";
|
||||||
|
export * from "./helpers";
|
||||||
export * from "./schemas";
|
export * from "./schemas";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
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 "./date-func";
|
||||||
export * from "./form-utils";
|
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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 (
|
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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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: [],
|
||||||
|
|
||||||
|
|||||||
@ -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 = ({
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user