diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json
index 5581e095..f20d67df 100644
--- a/modules/customer-invoices/src/common/locales/en.json
+++ b/modules/customer-invoices/src/common/locales/en.json
@@ -796,48 +796,108 @@
}
},
"sections": {
+ "summary": "Summary",
"customer": "Customer",
+ "document": "Document details",
"commercial": "Commercial terms",
"fiscal": "Tax",
+ "items": "Line items",
+ "taxes": "Taxes",
+ "totals": "Totals",
"verifactu": "VeriFactu",
"linked_proforma": "Linked proforma",
"content": "Content"
},
+ "section_descriptions": {
+ "summary": "Identity and primary data of the issued document.",
+ "customer": "Historical recipient snapshot materialized in the invoice.",
+ "document": "Document metadata and navigation to the source proforma when available.",
+ "commercial": "Payment method, payment term and due rules preserved in the snapshot.",
+ "fiscal": "Historical tax data available in the issued invoice.",
+ "items": "Materialized line items from the document, without frontend recalculation.",
+ "taxes": "Materialized tax breakdown returned by the API.",
+ "totals": "Historical totals of the issued invoice.",
+ "verifactu": "Current VeriFactu state from the singular block available today."
+ },
"fields": {
"invoice_reference": "Invoice number",
+ "invoice_number": "Sequential number",
"series": "Series",
"status": "Status",
"invoice_date": "Invoice date",
"operation_date": "Operation date",
"line_count": "Lines",
"total_units": "Units",
+ "currency": "Currency",
"total_amount": "Total amount",
"customer_name": "Customer",
"customer_tin": "TIN",
"address": "Address",
"payment_method": "Payment method",
+ "payment_method_description": "Payment method description",
"payment_term": "Payment term",
+ "payment_term_description": "Payment term description",
+ "payment_term_due_rules": "Due rules",
"language": "Language",
"internal_reference": "Internal reference",
"tax_regime": "Tax regime",
+ "fiscal_tax_regime": "Historical tax regime",
+ "operation_qualification": "Operation qualification",
+ "exemption_reason": "Exemption reason",
+ "verifactu_profile_status": "VeriFactu status",
+ "verifactu_policy": "VeriFactu policy",
+ "verifactu_effective_at": "VeriFactu effective date",
"subtotal_amount": "Subtotal",
+ "items_discount_amount": "Item discount",
+ "global_discount_amount": "Global discount",
"discount_amount": "Discount",
"taxable_amount": "Taxable amount",
"tax_amount": "Tax amount",
+ "taxes_amount": "Total taxes",
"rec_amount": "Equivalence surcharge",
"retention_amount": "Withholding",
"tax_breakdown": "Tax breakdown",
"verifactu_status": "VeriFactu status",
"verifactu_url": "VeriFactu URL",
+ "verifactu_qr": "VeriFactu QR",
"linked_proforma": "Linked proforma",
"description": "Description",
- "notes": "Notes"
+ "notes": "Notes",
+ "quantity": "Quantity",
+ "unit_amount": "Unit price",
+ "item_discount": "Line discount",
+ "item_taxes": "Line taxes"
+ },
+ "empty": {
+ "not_available": "—",
+ "no_address": "No address recorded.",
+ "no_description": "No description.",
+ "no_taxes": "No taxes reported."
},
"actions": {
"back": "Back",
"open_linked_proforma": "Open linked proforma",
"operation_in_progress": "The operation is in progress."
},
+ "payment_term": {
+ "due_days": "{{days}} days"
+ },
+ "items": {
+ "position": "Line {{position}}"
+ },
+ "verifactu": {
+ "empty": "This invoice has no VeriFactu block available in the current detail view.",
+ "qr_alt": "VeriFactu QR code",
+ "available": "Available",
+ "uses_verifactu": "Uses VeriFactu",
+ "does_not_use_verifactu": "Does not use VeriFactu",
+ "policy_status": {
+ "not_configured": "Not configured",
+ "exempt": "Exempt or not applicable",
+ "obligated_without_verifactu": "Obligated without VeriFactu",
+ "obligated_with_verifactu": "Obligated with VeriFactu"
+ }
+ },
"fiscal": {
"unknown_tax_code": "Tax"
}
diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json
index 86fe8a95..baecc2af 100644
--- a/modules/customer-invoices/src/common/locales/es.json
+++ b/modules/customer-invoices/src/common/locales/es.json
@@ -707,13 +707,13 @@
}
},
"grid_columns": {
- "series_invoice_number": "#",
+ "invoice_reference": "Num.",
"verifactu_status": "Estado",
"verifactu_qr_code": "QR",
"verifactu_url": "URL",
"invoice_number": "Nº factura",
"series": "Serie",
- "reference": "Reference",
+ "reference": "Referencia",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"recipient": "Cliente",
@@ -797,48 +797,108 @@
}
},
"sections": {
+ "summary": "Resumen",
"customer": "Cliente",
+ "document": "Datos del documento",
"commercial": "Condiciones comerciales",
"fiscal": "Fiscalidad",
+ "items": "Líneas",
+ "taxes": "Impuestos",
+ "totals": "Totales",
"verifactu": "VeriFactu",
"linked_proforma": "Proforma vinculada",
"content": "Contenido"
},
+ "section_descriptions": {
+ "summary": "Identidad y datos principales del documento emitido.",
+ "customer": "Snapshot histórico del destinatario materializado en la factura.",
+ "document": "Metadatos del documento y navegación a la proforma origen cuando existe.",
+ "commercial": "Forma, plazo y reglas de pago preservadas en el snapshot.",
+ "fiscal": "Datos fiscales históricos disponibles en la factura emitida.",
+ "items": "Líneas materializadas del documento, sin recálculo en frontend.",
+ "taxes": "Desglose fiscal materializado recibido desde la API.",
+ "totals": "Totales históricos de la factura emitida.",
+ "verifactu": "Estado actual de VeriFactu según el bloque singular disponible hoy."
+ },
"fields": {
"invoice_reference": "Número de factura",
+ "invoice_number": "Número correlativo",
"series": "Serie",
"status": "Estado",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"line_count": "Líneas",
"total_units": "Unidades",
+ "currency": "Moneda",
"total_amount": "Importe total",
"customer_name": "Cliente",
"customer_tin": "NIF",
"address": "Dirección",
"payment_method": "Método de pago",
+ "payment_method_description": "Descripción del método de pago",
"payment_term": "Plazo de pago",
+ "payment_term_description": "Descripción del plazo de pago",
+ "payment_term_due_rules": "Reglas de vencimiento",
"language": "Idioma",
"internal_reference": "Referencia interna",
"tax_regime": "Régimen fiscal",
+ "fiscal_tax_regime": "Régimen fiscal histórico",
+ "operation_qualification": "Calificación de operación",
+ "exemption_reason": "Motivo de exención",
+ "verifactu_profile_status": "Situación VeriFactu",
+ "verifactu_policy": "Política VeriFactu",
+ "verifactu_effective_at": "Fecha efectiva VeriFactu",
+ "items_discount_amount": "Descuento de líneas",
+ "global_discount_amount": "Descuento global",
"subtotal_amount": "Subtotal",
"discount_amount": "Descuento",
"taxable_amount": "Base imponible",
"tax_amount": "Impuestos",
+ "taxes_amount": "Total impuestos",
"rec_amount": "Recargo de equivalencia",
"retention_amount": "Retención",
"tax_breakdown": "Desglose fiscal",
"verifactu_status": "Estado VeriFactu",
"verifactu_url": "URL VeriFactu",
+ "verifactu_qr": "QR VeriFactu",
"linked_proforma": "Proforma vinculada",
"description": "Descripción",
- "notes": "Notas"
+ "notes": "Notas",
+ "quantity": "Cantidad",
+ "unit_amount": "Precio unitario",
+ "item_discount": "Descuento de línea",
+ "item_taxes": "Impuestos de línea"
+ },
+ "empty": {
+ "not_available": "—",
+ "no_address": "Sin dirección registrada.",
+ "no_description": "Sin descripción.",
+ "no_taxes": "Sin impuestos informados."
},
"actions": {
"back": "Volver",
"open_linked_proforma": "Abrir proforma vinculada",
"operation_in_progress": "La operación está en curso."
},
+ "payment_term": {
+ "due_days": "{{days}} días"
+ },
+ "items": {
+ "position": "Línea {{position}}"
+ },
+ "verifactu": {
+ "empty": "Esta factura no tiene un bloque VeriFactu disponible en el detalle actual.",
+ "qr_alt": "Código QR de VeriFactu",
+ "available": "Disponible",
+ "uses_verifactu": "Usa VeriFactu",
+ "does_not_use_verifactu": "No usa VeriFactu",
+ "policy_status": {
+ "not_configured": "No configurado",
+ "exempt": "Exenta o no aplicable",
+ "obligated_without_verifactu": "Obligada sin VeriFactu",
+ "obligated_with_verifactu": "Obligada con VeriFactu"
+ }
+ },
"fiscal": {
"unknown_tax_code": "Impuesto"
}
@@ -1135,4 +1195,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts b/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts
index 0f3b9039..bd9e8a3d 100644
--- a/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts
@@ -50,6 +50,7 @@ export interface IssuedInvoiceDetailSummaryViewModel {
totalLabel: string;
documentReference: string;
seriesLabel: string;
+ invoiceNumberLabel: string;
invoiceDateLabel: string;
operationDateLabel: string | null;
statusLabel: string;
@@ -64,12 +65,22 @@ export interface IssuedInvoiceDetailCustomerViewModel {
}
export interface IssuedInvoiceDetailCommercialViewModel {
- paymentMethodLabel: string | null;
- paymentTermLabel: string | null;
+ paymentMethodName: string | null;
+ paymentMethodDescription: string | null;
+ paymentTermName: string | null;
+ paymentTermDescription: string | null;
+ paymentTermDueRules: IssuedInvoiceDetailPaymentTermDueRuleViewModel[];
languageLabel: string;
+ currencyCode: string;
internalReferenceLabel: string | null;
}
+export interface IssuedInvoiceDetailPaymentTermDueRuleViewModel {
+ key: string;
+ dueDaysLabel: string;
+ percentageLabel: string;
+}
+
export interface IssuedInvoiceDetailFiscalBreakdownItemViewModel {
key: string;
taxLabel: string;
@@ -83,10 +94,15 @@ export interface IssuedInvoiceDetailFiscalBreakdownItemViewModel {
export interface IssuedInvoiceDetailFiscalViewModel {
taxRegimeLabel: string;
+ fiscalClassification: IssuedInvoiceDetailFiscalFieldViewModel[];
+ verifactuPolicy: IssuedInvoiceDetailFiscalFieldViewModel[];
subtotalLabel: string;
+ itemsDiscountLabel: string | null;
+ globalDiscountLabel: string | null;
discountLabel: string;
taxableAmountLabel: string;
taxAmountLabel: string;
+ taxesAmountLabel: string;
recAmountLabel: string | null;
retentionAmountLabel: string | null;
totalAmountLabel: string;
@@ -95,15 +111,24 @@ export interface IssuedInvoiceDetailFiscalViewModel {
usesRetention: boolean;
}
+export interface IssuedInvoiceDetailFiscalFieldViewModel {
+ key: string;
+ label: string;
+ value: string;
+}
+
export interface IssuedInvoiceDetailLinkedProformaViewModel {
linkedProformaId: string | null;
canOpenLinkedProforma: boolean;
}
export interface IssuedInvoiceDetailVerifactuViewModel {
- status: VerifactuRecordStatus;
+ status: VerifactuRecordStatus | null;
+ hasRecord: boolean;
url: string | null;
qrCode: string | null;
+ qrValue: string | null;
+ qrMode: "image" | "text" | "generated" | null;
}
export interface IssuedInvoiceDetailContentViewModel {
@@ -111,11 +136,54 @@ export interface IssuedInvoiceDetailContentViewModel {
notes: string | null;
}
+export interface IssuedInvoiceDetailItemViewModel {
+ key: string;
+ positionLabel: string;
+ description: string;
+ quantityLabel: string | null;
+ unitAmountLabel: string | null;
+ subtotalLabel: string;
+ discountLabel: string | null;
+ taxableAmountLabel: string;
+ taxesLabel: string;
+ totalAmountLabel: string;
+}
+
+export interface IssuedInvoiceDetailTaxBreakdownViewModel {
+ key: string;
+ taxLabel: string;
+ taxableAmountLabel: string;
+ taxAmountLabel: string;
+ taxesAmountLabel: string;
+ recLabel: string | null;
+ recAmountLabel: string | null;
+ retentionLabel: string | null;
+ retentionAmountLabel: string | null;
+}
+
+export interface IssuedInvoiceDetailTotalsViewModel {
+ subtotalLabel: string;
+ itemsDiscountLabel: string | null;
+ globalDiscountLabel: string | null;
+ discountLabel: string;
+ taxableAmountLabel: string;
+ taxAmountLabel: string;
+ taxesAmountLabel: string;
+ recAmountLabel: string | null;
+ retentionAmountLabel: string | null;
+ totalAmountLabel: string;
+ usesRec: boolean;
+ usesRetention: boolean;
+}
+
export interface IssuedInvoiceDetailGeneralViewModel {
summary: IssuedInvoiceDetailSummaryViewModel;
customer: IssuedInvoiceDetailCustomerViewModel;
commercial: IssuedInvoiceDetailCommercialViewModel;
fiscal: IssuedInvoiceDetailFiscalViewModel;
+ items: IssuedInvoiceDetailItemViewModel[];
+ taxes: IssuedInvoiceDetailTaxBreakdownViewModel[];
+ totals: IssuedInvoiceDetailTotalsViewModel;
linkedProforma: IssuedInvoiceDetailLinkedProformaViewModel;
verifactu: IssuedInvoiceDetailVerifactuViewModel;
content: IssuedInvoiceDetailContentViewModel;
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx
index 15722db2..81aab08e 100644
--- a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx
@@ -2,8 +2,9 @@ import { Button } from "@repo/shadcn-ui/components";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
+import { ProformaDetailField } from "../../../../proformas/detail/ui/components";
+import { VerifactuStatusBadge } from "../../../list/ui/components";
import type { IssuedInvoiceDetailGeneralViewModel } from "../../entities";
-import { IssuedInvoiceDetailField } from "../components";
export interface IssuedInvoiceGeneralPanelProps {
general: IssuedInvoiceDetailGeneralViewModel;
@@ -12,14 +13,69 @@ export interface IssuedInvoiceGeneralPanelProps {
const EMPTY_VALUE = "\u2014";
+const buildPaymentTermLabel = (general: IssuedInvoiceDetailGeneralViewModel) => {
+ const dueRulesSummary = general.commercial.paymentTermDueRules
+ .map((rule) => `${rule.percentageLabel} · ${rule.dueDaysLabel}`)
+ .join(" / ");
+
+ return [
+ general.commercial.paymentTermName,
+ general.commercial.paymentTermDescription,
+ dueRulesSummary || null,
+ ]
+ .filter(Boolean)
+ .join(" — ");
+};
+
export const IssuedInvoiceGeneralPanel = ({
general,
onOpenLinkedProforma,
}: IssuedInvoiceGeneralPanelProps) => {
const { t } = useTranslation();
+ const paymentTermLabel = buildPaymentTermLabel(general);
return (
+
+
+ {t("pages.issued_invoices.detail.sections.verifactu")}
+
+
+
+ ) : (
+ EMPTY_VALUE
+ )
+ }
+ />
+
+ {general.verifactu.url}
+
+ ) : (
+ EMPTY_VALUE
+ )
+ }
+ valueClassName="sm:text-left"
+ />
+
+
+
+
{general.summary.totalLabel}
@@ -32,42 +88,35 @@ export const IssuedInvoiceGeneralPanel = ({
-
-
-
-
-
-
-
-
{
+ const headingId = useId();
+
+ return (
+
+
+
+
+ {title}
+
+ {description ? (
+
{description}
+ ) : null}
+
+ {action ?
{action}
: null}
+
+
+ {children}
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-skeleton.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-skeleton.tsx
new file mode 100644
index 00000000..005df1b4
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-skeleton.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from "@repo/shadcn-ui/components";
+
+export const IssuedInvoiceDetailSkeleton = () => {
+ return (
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.test.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.test.tsx
new file mode 100644
index 00000000..4d6a5a86
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.test.tsx
@@ -0,0 +1,374 @@
+import assert from "node:assert/strict";
+import { mock, test } from "node:test";
+
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+const buildController = () => ({
+ state: {
+ isInitialLoading: false,
+ isRefetching: false,
+ pageError: null,
+ },
+ documentState: {
+ isInitialLoading: false,
+ isFetching: false,
+ isError: false,
+ errorMessage: null,
+ },
+ header: {
+ title: "Factura A-12",
+ documentReference: "A-12",
+ customerName: "Acme Labs",
+ status: "issued" as const,
+ statusLabel: "Emitida",
+ invoiceDateLabel: "10/08/2026",
+ },
+ document: {
+ title: "Documento",
+ description: "PDF",
+ src: "blob:invoice",
+ mode: "pdf" as const,
+ capabilities: {
+ canOpen: true,
+ canDownload: true,
+ canPrint: true,
+ },
+ },
+ general: {
+ summary: {
+ totalLabel: "121,00 €",
+ documentReference: "A-12",
+ seriesLabel: "A",
+ invoiceNumberLabel: "12",
+ invoiceDateLabel: "10/08/2026",
+ operationDateLabel: "09/08/2026",
+ statusLabel: "Emitida",
+ lineCount: 1,
+ totalUnitsLabel: "2",
+ },
+ customer: {
+ name: "Acme Labs",
+ tin: "B12345678",
+ addressLines: ["Calle Mayor 1", "28001 Madrid", "ES"],
+ },
+ commercial: {
+ paymentMethodName: "Transferencia",
+ paymentMethodDescription: "Transferencia bancaria",
+ paymentTermName: "30 días",
+ paymentTermDescription: "Pago estándar",
+ paymentTermDueRules: [],
+ languageLabel: "español",
+ currencyCode: "EUR",
+ internalReferenceLabel: "REF-CLI-12",
+ },
+ fiscal: {
+ taxRegimeLabel: "Régimen general",
+ fiscalClassification: [],
+ verifactuPolicy: [],
+ subtotalLabel: "100,00 €",
+ itemsDiscountLabel: null,
+ globalDiscountLabel: null,
+ discountLabel: "0,00 €",
+ taxableAmountLabel: "100,00 €",
+ taxAmountLabel: "21,00 €",
+ taxesAmountLabel: "21,00 €",
+ recAmountLabel: null,
+ retentionAmountLabel: null,
+ totalAmountLabel: "121,00 €",
+ breakdown: [],
+ usesRec: false,
+ usesRetention: false,
+ },
+ items: [],
+ taxes: [],
+ totals: {
+ subtotalLabel: "100,00 €",
+ itemsDiscountLabel: null,
+ globalDiscountLabel: null,
+ discountLabel: "0,00 €",
+ taxableAmountLabel: "100,00 €",
+ taxAmountLabel: "21,00 €",
+ taxesAmountLabel: "21,00 €",
+ recAmountLabel: null,
+ retentionAmountLabel: null,
+ totalAmountLabel: "121,00 €",
+ usesRec: false,
+ usesRetention: false,
+ },
+ linkedProforma: {
+ linkedProformaId: "proforma-1",
+ canOpenLinkedProforma: true,
+ },
+ verifactu: {
+ hasRecord: false,
+ status: null,
+ url: null,
+ qrCode: null,
+ qrValue: null,
+ qrMode: null as const,
+ },
+ content: {
+ description: "Factura de prueba",
+ notes: null,
+ },
+ },
+ actions: {
+ items: [],
+ },
+ navigation: {
+ navigateBack: () => undefined,
+ openLinkedProforma: () => undefined,
+ },
+ handlers: {
+ executeAction: () => undefined,
+ },
+ viewer: {
+ handleDownload: async () => undefined,
+ handleOpenPreviewPdf: async () => undefined,
+ handlePrintPreview: () => undefined,
+ handleRetryDocument: async () => undefined,
+ },
+});
+
+test("muestra el skeleton mientras la pantalla está cargando", async () => {
+ let skeletonRendered = false;
+
+ mock.module("../../../../i18n", {
+ namedExports: {
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ },
+ });
+
+ mock.module("../../controllers", {
+ namedExports: {
+ useIssuedInvoiceDetailPageController: () => ({
+ ...buildController(),
+ state: {
+ isInitialLoading: true,
+ isRefetching: false,
+ pageError: null,
+ },
+ }),
+ },
+ });
+
+ mock.module("../blocks", {
+ namedExports: {
+ IssuedInvoiceDetailContent: () => null,
+ IssuedInvoiceDetailHeader: () => null,
+ IssuedInvoiceDetailInspector: () => null,
+ IssuedInvoiceDocumentPane: () => null,
+ },
+ });
+
+ mock.module("../components", {
+ namedExports: {
+ IssuedInvoiceDetailSkeleton: () => {
+ skeletonRendered = true;
+ return null;
+ },
+ },
+ });
+
+ mock.module("@erp/core/components", {
+ namedExports: {
+ PageErrorState: () => null,
+ },
+ });
+
+ const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
+ renderToStaticMarkup();
+
+ assert.equal(skeletonRendered, true);
+ mock.restoreAll();
+});
+
+test("usa PageErrorState para el error inicial bloqueante", async () => {
+ let capturedErrorProps:
+ | {
+ title?: string;
+ variant?: string;
+ reference?: string;
+ }
+ | undefined;
+
+ mock.module("../../../../i18n", {
+ namedExports: {
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ },
+ });
+
+ mock.module("../../controllers", {
+ namedExports: {
+ useIssuedInvoiceDetailPageController: () => ({
+ ...buildController(),
+ state: {
+ isInitialLoading: false,
+ isRefetching: false,
+ pageError: {
+ variant: "server",
+ title: "No se pudo cargar la factura",
+ description: "Error",
+ primaryAction: undefined,
+ secondaryAction: undefined,
+ reference: "corr-1",
+ },
+ },
+ }),
+ },
+ });
+
+ mock.module("@erp/core/components", {
+ namedExports: {
+ PageErrorState: (props: {
+ title?: string;
+ variant?: string;
+ reference?: string;
+ }) => {
+ capturedErrorProps = props;
+ return null;
+ },
+ },
+ });
+
+ mock.module("../blocks", {
+ namedExports: {
+ IssuedInvoiceDetailContent: () => null,
+ IssuedInvoiceDetailHeader: () => null,
+ IssuedInvoiceDetailInspector: () => null,
+ IssuedInvoiceDocumentPane: () => null,
+ },
+ });
+
+ mock.module("../components", {
+ namedExports: {
+ IssuedInvoiceDetailSkeleton: () => null,
+ },
+ });
+
+ const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
+ renderToStaticMarkup();
+
+ assert.equal(capturedErrorProps?.title, "No se pudo cargar la factura");
+ assert.equal(capturedErrorProps?.variant, "server");
+ assert.equal(capturedErrorProps?.reference, "corr-1");
+ mock.restoreAll();
+});
+
+test("compone header, visor e inspector con el detalle emitido y la navegación a proforma", async () => {
+ let capturedInspectorProps:
+ | {
+ general?: {
+ linkedProforma?: {
+ linkedProformaId?: string | null;
+ };
+ verifactu?: {
+ hasRecord?: boolean;
+ };
+ };
+ onOpenLinkedProforma?: () => void;
+ }
+ | undefined;
+ let capturedHeaderProps:
+ | {
+ header?: {
+ documentReference?: string;
+ };
+ }
+ | undefined;
+ let capturedDocumentPaneProps:
+ | {
+ document?: {
+ src?: string | null;
+ };
+ }
+ | undefined;
+
+ mock.module("../../../../i18n", {
+ namedExports: {
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ },
+ });
+
+ mock.module("../../controllers", {
+ namedExports: {
+ useIssuedInvoiceDetailPageController: () => buildController(),
+ },
+ });
+
+ mock.module("@erp/core/components", {
+ namedExports: {
+ PageErrorState: () => null,
+ },
+ });
+
+ mock.module("../components", {
+ namedExports: {
+ IssuedInvoiceDetailSkeleton: () => null,
+ },
+ });
+
+ mock.module("../blocks", {
+ namedExports: {
+ IssuedInvoiceDetailHeader: (props: {
+ header?: {
+ documentReference?: string;
+ };
+ }) => {
+ capturedHeaderProps = props;
+ return null;
+ },
+ IssuedInvoiceDetailContent: ({
+ documentPane,
+ inspector,
+ }: {
+ documentPane: ReactNode;
+ inspector: ReactNode;
+ }) => (
+ <>
+ {documentPane}
+ {inspector}
+ >
+ ),
+ IssuedInvoiceDocumentPane: (props: {
+ document?: {
+ src?: string | null;
+ };
+ }) => {
+ capturedDocumentPaneProps = props;
+ return null;
+ },
+ IssuedInvoiceDetailInspector: (props: {
+ general?: {
+ linkedProforma?: {
+ linkedProformaId?: string | null;
+ };
+ verifactu?: {
+ hasRecord?: boolean;
+ };
+ };
+ onOpenLinkedProforma?: () => void;
+ }) => {
+ capturedInspectorProps = props;
+ return null;
+ },
+ },
+ });
+
+ const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
+ renderToStaticMarkup();
+
+ assert.equal(capturedHeaderProps?.header?.documentReference, "A-12");
+ assert.equal(capturedDocumentPaneProps?.document?.src, "blob:invoice");
+ assert.equal(capturedInspectorProps?.general?.linkedProforma?.linkedProformaId, "proforma-1");
+ assert.equal(typeof capturedInspectorProps?.onOpenLinkedProforma, "function");
+ assert.equal(capturedInspectorProps?.general?.verifactu?.hasRecord, false);
+ mock.restoreAll();
+});
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.tsx
index ef55f15d..11df61a4 100644
--- a/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.tsx
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.tsx
@@ -1,5 +1,4 @@
import { PageErrorState } from "@erp/core/components";
-import { Spinner } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceDetailPageController } from "../../controllers";
@@ -9,20 +8,14 @@ import {
IssuedInvoiceDetailInspector,
IssuedInvoiceDocumentPane,
} from "../blocks";
+import { IssuedInvoiceDetailSkeleton } from "../components";
export const IssuedInvoiceDetailPage = () => {
const { t } = useTranslation();
const ctrl = useIssuedInvoiceDetailPageController();
if (ctrl.state.isInitialLoading) {
- return (
-
-
-
- {t("pages.issued_invoices.detail.document.loading")}
-
-
- );
+ return ;
}
if (ctrl.state.pageError) {
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/index.ts
new file mode 100644
index 00000000..090ea250
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/index.ts
@@ -0,0 +1,9 @@
+export * from "./issued-invoice-document-section";
+export * from "./issued-invoice-fiscal-section";
+export * from "./issued-invoice-items-section";
+export * from "./issued-invoice-payment-section";
+export * from "./issued-invoice-recipient-section";
+export * from "./issued-invoice-summary-section";
+export * from "./issued-invoice-taxes-section";
+export * from "./issued-invoice-totals-section";
+export * from "./issued-invoice-verifactu-section";
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-document-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-document-section.tsx
new file mode 100644
index 00000000..5cb5d1c9
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-document-section.tsx
@@ -0,0 +1,71 @@
+import { Button } from "@repo/shadcn-ui/components";
+import { ExternalLinkIcon } from "lucide-react";
+
+import { useTranslation } from "../../../../i18n";
+import type {
+ IssuedInvoiceDetailCommercialViewModel,
+ IssuedInvoiceDetailLinkedProformaViewModel,
+ IssuedInvoiceDetailSummaryViewModel,
+} from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceDocumentSectionProps = {
+ summary: IssuedInvoiceDetailSummaryViewModel;
+ commercial: IssuedInvoiceDetailCommercialViewModel;
+ linkedProforma: IssuedInvoiceDetailLinkedProformaViewModel;
+ onOpenLinkedProforma: () => void;
+};
+
+export const IssuedInvoiceDocumentSection = ({
+ summary,
+ commercial,
+ linkedProforma,
+ onOpenLinkedProforma,
+}: IssuedInvoiceDocumentSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+ {t("pages.issued_invoices.detail.actions.open_linked_proforma")}
+
+ ) : (
+ t("pages.issued_invoices.detail.empty.not_available")
+ )
+ }
+ valueClassName="sm:text-right"
+ />
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-fiscal-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-fiscal-section.tsx
new file mode 100644
index 00000000..ead6d4d2
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-fiscal-section.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailFiscalViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceFiscalSectionProps = {
+ fiscal: IssuedInvoiceDetailFiscalViewModel;
+};
+
+export const IssuedInvoiceFiscalSection = ({
+ fiscal,
+}: IssuedInvoiceFiscalSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {fiscal.fiscalClassification.map((field) => (
+
+ ))}
+ {fiscal.verifactuPolicy.map((field) => (
+
+ ))}
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-items-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-items-section.tsx
new file mode 100644
index 00000000..f05e3fc9
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-items-section.tsx
@@ -0,0 +1,73 @@
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailItemViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceItemsSectionProps = {
+ items: IssuedInvoiceDetailItemViewModel[];
+};
+
+export const IssuedInvoiceItemsSection = ({ items }: IssuedInvoiceItemsSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {items.map((item) => (
+
+
+
+ {t("pages.issued_invoices.detail.items.position", {
+ position: item.positionLabel,
+ })}
+
+
{item.description}
+
+
+
+
+
+
+ {item.discountLabel ? (
+
+ ) : null}
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-payment-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-payment-section.tsx
new file mode 100644
index 00000000..c0e040d0
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-payment-section.tsx
@@ -0,0 +1,63 @@
+import { Badge } from "@repo/shadcn-ui/components";
+
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailCommercialViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoicePaymentSectionProps = {
+ commercial: IssuedInvoiceDetailCommercialViewModel;
+};
+
+export const IssuedInvoicePaymentSection = ({
+ commercial,
+}: IssuedInvoicePaymentSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {commercial.paymentMethodDescription ? (
+
+ ) : null}
+
+ {commercial.paymentTermDescription ? (
+
+ ) : null}
+
0 ? (
+
+ {commercial.paymentTermDueRules.map((rule) => (
+
+ {rule.percentageLabel} · {rule.dueDaysLabel}
+
+ ))}
+
+ ) : (
+ t("pages.issued_invoices.detail.empty.not_available")
+ )
+ }
+ />
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-recipient-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-recipient-section.tsx
new file mode 100644
index 00000000..100dde56
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-recipient-section.tsx
@@ -0,0 +1,46 @@
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailCustomerViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceRecipientSectionProps = {
+ customer: IssuedInvoiceDetailCustomerViewModel;
+};
+
+export const IssuedInvoiceRecipientSection = ({
+ customer,
+}: IssuedInvoiceRecipientSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
0 ? (
+
+ {customer.addressLines.map((line) => (
+
{line}
+ ))}
+
+ ) : (
+ t("pages.issued_invoices.detail.empty.no_address")
+ )
+ }
+ valueClassName="sm:text-left"
+ />
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-summary-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-summary-section.tsx
new file mode 100644
index 00000000..cb5eded3
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-summary-section.tsx
@@ -0,0 +1,82 @@
+import { useTranslation } from "../../../../i18n";
+import type {
+ IssuedInvoiceDetailContentViewModel,
+ IssuedInvoiceDetailSummaryViewModel,
+} from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceSummarySectionProps = {
+ summary: IssuedInvoiceDetailSummaryViewModel;
+ content: IssuedInvoiceDetailContentViewModel;
+};
+
+export const IssuedInvoiceSummarySection = ({
+ summary,
+ content,
+}: IssuedInvoiceSummarySectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t("pages.issued_invoices.detail.fields.total_amount")}
+
+
+ {summary.totalLabel}
+
+
+
+
+
+
+
+
+
+
+ {summary.totalUnitsLabel ? (
+
+ ) : null}
+ {content.description ? (
+
+ ) : null}
+ {content.notes ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-taxes-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-taxes-section.tsx
new file mode 100644
index 00000000..436bf156
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-taxes-section.tsx
@@ -0,0 +1,57 @@
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailTaxBreakdownViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceTaxesSectionProps = {
+ taxes: IssuedInvoiceDetailTaxBreakdownViewModel[];
+};
+
+export const IssuedInvoiceTaxesSection = ({ taxes }: IssuedInvoiceTaxesSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {taxes.map((item) => (
+
+
{item.taxLabel}
+
+
+
+
+ {item.recLabel && item.recAmountLabel ? (
+
+ ) : null}
+ {item.retentionLabel && item.retentionAmountLabel ? (
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-totals-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-totals-section.tsx
new file mode 100644
index 00000000..0284caae
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-totals-section.tsx
@@ -0,0 +1,79 @@
+import { useTranslation } from "../../../../i18n";
+import type { IssuedInvoiceDetailTotalsViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceTotalsSectionProps = {
+ totals: IssuedInvoiceDetailTotalsViewModel;
+};
+
+export const IssuedInvoiceTotalsSection = ({ totals }: IssuedInvoiceTotalsSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {totals.itemsDiscountLabel ? (
+
+ ) : null}
+ {totals.globalDiscountLabel ? (
+
+ ) : null}
+
+
+
+
+ {totals.usesRec ? (
+
+ ) : null}
+ {totals.usesRetention ? (
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-verifactu-section.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-verifactu-section.tsx
new file mode 100644
index 00000000..0083fb75
--- /dev/null
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-verifactu-section.tsx
@@ -0,0 +1,99 @@
+import { ReactQRCode } from "@lglab/react-qr-code";
+
+import { useTranslation } from "../../../../i18n";
+import { VerifactuStatusBadge } from "../../../list/ui/components";
+import type { IssuedInvoiceDetailVerifactuViewModel } from "../../entities";
+import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
+
+type IssuedInvoiceVerifactuSectionProps = {
+ verifactu: IssuedInvoiceDetailVerifactuViewModel;
+};
+
+export const IssuedInvoiceVerifactuSection = ({
+ verifactu,
+}: IssuedInvoiceVerifactuSectionProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {!verifactu.hasRecord ? (
+
+ {t("pages.issued_invoices.detail.verifactu.empty")}
+
+ ) : (
+
+
+ ) : (
+ t("pages.issued_invoices.detail.empty.not_available")
+ )
+ }
+ />
+ {verifactu.url ? (
+
+ {verifactu.url}
+
+ }
+ valueClassName="sm:text-left"
+ />
+ ) : null}
+ {verifactu.qrMode === "image" && verifactu.qrValue ? (
+
+ }
+ />
+ ) : null}
+ {verifactu.qrMode === "generated" && verifactu.qrValue ? (
+
+
+
+ }
+ />
+ ) : null}
+ {verifactu.qrMode === "text" && verifactu.qrValue ? (
+
+ {verifactu.qrValue}
+
+ }
+ valueClassName="sm:text-left"
+ />
+ ) : null}
+
+ )}
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.test.ts b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.test.ts
index 8683a5e9..84d05df7 100644
--- a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.test.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.test.ts
@@ -111,6 +111,19 @@ const buildInvoice = (): IssuedInvoice => ({
},
taxRegimeCode: "general",
taxRegimeDescription: null,
+ fiscalClassification: {
+ taxRegimeCode: "general",
+ taxRegimeLabel: "Régimen general",
+ operationQualificationCode: "S1",
+ operationQualificationLabel: "Sujeta",
+ exemptionReasonCode: null,
+ exemptionReasonLabel: null,
+ },
+ verifactuPolicy: {
+ profileStatus: "obligated_with_verifactu",
+ usesVerifactu: true,
+ effectiveAt: "2026-01-01T00:00:00.000Z",
+ },
subtotalAmount: 150,
itemsDiscountAmount: 0,
globalDiscountPercentage: 0,
@@ -187,22 +200,42 @@ test("construye el view model general con resumen fiscal, unidades y enlace a pr
});
assert.equal(viewModel.summary.documentReference, "A-12");
+ assert.equal(viewModel.summary.invoiceNumberLabel, "12");
assert.equal(viewModel.summary.statusLabel, "Emitida");
assert.equal(viewModel.summary.totalUnitsLabel, "3.5");
assert.equal(viewModel.customer.addressLines.length, 3);
- assert.equal(viewModel.commercial.paymentMethodLabel, null);
- assert.equal(
- viewModel.commercial.paymentTermLabel,
- "30 días — Pago estándar — 100.00% a 30 días"
- );
+ assert.equal(viewModel.commercial.paymentMethodName, null);
+ assert.equal(viewModel.commercial.paymentTermName, "30 días");
+ assert.equal(viewModel.commercial.paymentTermDueRules[0]?.dueDaysLabel, "30 días");
assert.equal(viewModel.fiscal.taxRegimeLabel, "general");
+ assert.equal(viewModel.fiscal.fiscalClassification[0]?.value, "Régimen general");
+ assert.equal(viewModel.fiscal.verifactuPolicy[0]?.value, "obligated_with_verifactu");
assert.equal(viewModel.fiscal.usesRec, true);
assert.equal(viewModel.fiscal.usesRetention, true);
assert.equal(viewModel.fiscal.breakdown[1]?.taxLabel, "Impuesto");
assert.equal(viewModel.fiscal.breakdown[1]?.recLabel, "RE · 5.2%");
assert.equal(viewModel.fiscal.breakdown[1]?.retentionLabel, "IRPF · 15%");
+ assert.equal(viewModel.items[0]?.positionLabel, "1");
+ assert.equal(viewModel.totals.taxesAmountLabel, "16,10 €");
assert.equal(viewModel.linkedProforma.canOpenLinkedProforma, true);
assert.equal(viewModel.verifactu.url, "https://example.com/verifactu/A-12");
+ assert.equal(viewModel.verifactu.qrMode, "generated");
+});
+
+test("mantiene verifactu a null sin fabricar estado ni qr", () => {
+ const viewModel = buildIssuedInvoiceDetailGeneralViewModel(
+ {
+ ...buildInvoice(),
+ verifactu: null,
+ },
+ {
+ translate,
+ }
+ );
+
+ assert.equal(viewModel.verifactu.hasRecord, false);
+ assert.equal(viewModel.verifactu.status, null);
+ assert.equal(viewModel.verifactu.qrMode, null);
});
test("mapea errores desconocidos del detalle al variant unknown sin exponer detalles técnicos", () => {
diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts
index 0a7e908c..5a47f50b 100644
--- a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts
@@ -8,12 +8,16 @@ import type {
IssuedInvoiceDetailContentViewModel,
IssuedInvoiceDetailCustomerViewModel,
IssuedInvoiceDetailDocumentViewModel,
+ IssuedInvoiceDetailFiscalFieldViewModel,
IssuedInvoiceDetailFiscalBreakdownItemViewModel,
IssuedInvoiceDetailFiscalViewModel,
IssuedInvoiceDetailGeneralViewModel,
IssuedInvoiceDetailHeaderViewModel,
+ IssuedInvoiceDetailItemViewModel,
IssuedInvoiceDetailPageErrorViewModel,
IssuedInvoiceDetailSummaryViewModel,
+ IssuedInvoiceDetailTaxBreakdownViewModel,
+ IssuedInvoiceDetailTotalsViewModel,
IssuedInvoiceDetailVerifactuViewModel,
} from "../entities";
@@ -97,32 +101,55 @@ const buildTaxLabel = (
return percentage ? `${code} · ${percentage}` : code;
};
-const buildPaymentTermLabel = (issuedInvoice: IssuedInvoice): string | null => {
- if (issuedInvoice.paymentTerm == null) {
+const buildPercentageLabel = (value: number | null, scale = 2) => {
+ if (value === null) {
return null;
}
- const dueRulesSummary = issuedInvoice.paymentTerm.dueRules
- .map((rule) => {
- const percentageLabel = NumberHelper.formatNumber(
- PercentageDTOHelper.toNumber({
- value: rule.percentage.value,
- scale: String(rule.percentage.scale),
- }),
- rule.percentage.scale
- );
+ return `${NumberHelper.formatNumber(value, scale)}%`;
+};
- return `${percentageLabel}% a ${rule.dueDays} días`;
- })
- .join(" / ");
+const buildPolicyStatusLabel = (
+ profileStatus: NonNullable
["profileStatus"],
+ translate: IssuedInvoiceDetailTranslate
+) => {
+ return translate(`pages.issued_invoices.detail.verifactu.policy_status.${profileStatus}`, {
+ defaultValue: profileStatus,
+ });
+};
- const segments = [
- issuedInvoice.paymentTerm.name,
- issuedInvoice.paymentTerm.description,
- dueRulesSummary.length > 0 ? dueRulesSummary : null,
+const buildItemTaxesLabel = (
+ item: IssuedInvoice["items"][number],
+ translate: IssuedInvoiceDetailTranslate
+) => {
+ const taxPercentageLabel = buildPercentageLabel(item.taxPercentage);
+ const recPercentageLabel = buildPercentageLabel(item.recPercentage);
+ const retentionPercentageLabel = buildPercentageLabel(item.retentionPercentage);
+ const parts = [
+ item.taxCode ? (taxPercentageLabel ? `${item.taxCode} · ${taxPercentageLabel}` : item.taxCode) : null,
+ item.recCode ? (recPercentageLabel ? `${item.recCode} · ${recPercentageLabel}` : item.recCode) : null,
+ item.retentionCode
+ ? retentionPercentageLabel
+ ? `${item.retentionCode} · ${retentionPercentageLabel}`
+ : item.retentionCode
+ : null,
].filter(Boolean);
- return segments.length > 0 ? segments.join(" — ") : null;
+ return parts.length > 0
+ ? parts.join(" / ")
+ : translate("pages.issued_invoices.detail.empty.no_taxes");
+};
+
+const buildFiscalField = (
+ key: string,
+ label: string,
+ value: string | null
+): IssuedInvoiceDetailFiscalFieldViewModel | null => {
+ if (!value) {
+ return null;
+ }
+
+ return { key, label, value };
};
const buildPageErrorCopy = (
@@ -241,6 +268,14 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
): IssuedInvoiceDetailGeneralViewModel => {
const usesRec = issuedInvoice.recAmount > 0;
const usesRetention = issuedInvoice.retentionAmount > 0;
+ const itemsDiscountLabel =
+ issuedInvoice.itemsDiscountAmount > 0
+ ? formatMoney(issuedInvoice, issuedInvoice.itemsDiscountAmount)
+ : null;
+ const globalDiscountLabel =
+ issuedInvoice.globalDiscountAmount > 0
+ ? `${formatMoney(issuedInvoice, issuedInvoice.globalDiscountAmount)} (${NumberHelper.formatNumber(issuedInvoice.globalDiscountPercentage, 2)}%)`
+ : null;
const breakdown: IssuedInvoiceDetailFiscalBreakdownItemViewModel[] = issuedInvoice.taxes.map(
(tax, index) => ({
@@ -248,6 +283,7 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
taxLabel: buildTaxLabel(tax, context.translate),
taxableAmountLabel: formatMoney(issuedInvoice, tax.taxableAmount),
taxAmountLabel: formatMoney(issuedInvoice, tax.ivaAmount),
+ taxesAmountLabel: formatMoney(issuedInvoice, tax.taxesAmount),
recLabel:
tax.recPercentage === null
? null
@@ -266,6 +302,7 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
totalLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount),
documentReference: issuedInvoice.invoiceReference,
seriesLabel: issuedInvoice.series,
+ invoiceNumberLabel: issuedInvoice.invoiceNumber,
invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate),
operationDateLabel: formatDate(issuedInvoice.operationDate),
statusLabel: context.translate(
@@ -285,18 +322,86 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
};
const commercial: IssuedInvoiceDetailCommercialViewModel = {
- paymentMethodLabel: issuedInvoice.paymentMethodName,
- paymentTermLabel: buildPaymentTermLabel(issuedInvoice),
+ paymentMethodName: issuedInvoice.paymentMethodName,
+ paymentMethodDescription: issuedInvoice.paymentMethodDescription,
+ paymentTermName: issuedInvoice.paymentTerm?.name ?? null,
+ paymentTermDescription: issuedInvoice.paymentTerm?.description ?? null,
+ paymentTermDueRules:
+ issuedInvoice.paymentTerm?.dueRules.map((rule, index) => ({
+ key: `${rule.dueDays}-${rule.percentage.value}-${index}`,
+ dueDaysLabel: context.translate("pages.issued_invoices.detail.payment_term.due_days", {
+ days: rule.dueDays,
+ }),
+ percentageLabel: `${NumberHelper.formatNumber(
+ PercentageDTOHelper.toNumber({
+ value: rule.percentage.value,
+ scale: String(rule.percentage.scale),
+ }),
+ rule.percentage.scale
+ )}%`,
+ })) ?? [],
languageLabel: buildLanguageLabel(issuedInvoice.languageCode),
+ currencyCode: issuedInvoice.currencyCode,
internalReferenceLabel: issuedInvoice.reference,
};
+ const fiscalClassification = [
+ buildFiscalField(
+ "tax-regime",
+ context.translate("pages.issued_invoices.detail.fields.fiscal_tax_regime"),
+ issuedInvoice.fiscalClassification?.taxRegimeLabel ??
+ issuedInvoice.fiscalClassification?.taxRegimeCode ??
+ null
+ ),
+ buildFiscalField(
+ "operation-qualification",
+ context.translate("pages.issued_invoices.detail.fields.operation_qualification"),
+ issuedInvoice.fiscalClassification?.operationQualificationLabel ??
+ issuedInvoice.fiscalClassification?.operationQualificationCode ??
+ null
+ ),
+ buildFiscalField(
+ "exemption-reason",
+ context.translate("pages.issued_invoices.detail.fields.exemption_reason"),
+ issuedInvoice.fiscalClassification?.exemptionReasonLabel ??
+ issuedInvoice.fiscalClassification?.exemptionReasonCode ??
+ null
+ ),
+ ].filter((item): item is IssuedInvoiceDetailFiscalFieldViewModel => item !== null);
+
+ const verifactuPolicy = issuedInvoice.verifactuPolicy
+ ? [
+ buildFiscalField(
+ "profile-status",
+ context.translate("pages.issued_invoices.detail.fields.verifactu_profile_status"),
+ buildPolicyStatusLabel(issuedInvoice.verifactuPolicy.profileStatus, context.translate)
+ ),
+ buildFiscalField(
+ "uses-verifactu",
+ context.translate("pages.issued_invoices.detail.fields.verifactu_policy"),
+ issuedInvoice.verifactuPolicy.usesVerifactu
+ ? context.translate("pages.issued_invoices.detail.verifactu.uses_verifactu")
+ : context.translate("pages.issued_invoices.detail.verifactu.does_not_use_verifactu")
+ ),
+ buildFiscalField(
+ "effective-at",
+ context.translate("pages.issued_invoices.detail.fields.verifactu_effective_at"),
+ formatDate(issuedInvoice.verifactuPolicy.effectiveAt)
+ ),
+ ].filter((item): item is IssuedInvoiceDetailFiscalFieldViewModel => item !== null)
+ : [];
+
const fiscal: IssuedInvoiceDetailFiscalViewModel = {
taxRegimeLabel: issuedInvoice.taxRegimeDescription ?? issuedInvoice.taxRegimeCode,
+ fiscalClassification,
+ verifactuPolicy,
subtotalLabel: formatMoney(issuedInvoice, issuedInvoice.subtotalAmount),
+ itemsDiscountLabel,
+ globalDiscountLabel,
discountLabel: formatMoney(issuedInvoice, issuedInvoice.totalDiscountAmount),
taxableAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxableAmount),
taxAmountLabel: formatMoney(issuedInvoice, issuedInvoice.ivaAmount),
+ taxesAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxesAmount),
recAmountLabel: usesRec ? formatMoney(issuedInvoice, issuedInvoice.recAmount) : null,
retentionAmountLabel: usesRetention
? formatMoney(issuedInvoice, issuedInvoice.retentionAmount)
@@ -308,9 +413,63 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
};
const verifactu: IssuedInvoiceDetailVerifactuViewModel = {
- status: issuedInvoice.verifactu.status,
- url: issuedInvoice.verifactu.url,
- qrCode: issuedInvoice.verifactu.qrCode,
+ hasRecord: issuedInvoice.verifactu !== null,
+ status: issuedInvoice.verifactu?.status ?? null,
+ url: issuedInvoice.verifactu?.url ?? null,
+ qrCode: issuedInvoice.verifactu?.qrCode ?? null,
+ qrValue: issuedInvoice.verifactu?.qrCode ?? issuedInvoice.verifactu?.url ?? null,
+ qrMode: issuedInvoice.verifactu?.qrCode
+ ? issuedInvoice.verifactu.qrCode.startsWith("data:image/")
+ ? "image"
+ : "text"
+ : issuedInvoice.verifactu?.url
+ ? "generated"
+ : null,
+ };
+
+ const items: IssuedInvoiceDetailItemViewModel[] = issuedInvoice.items.map((item) => ({
+ key: item.id,
+ positionLabel: String(item.position),
+ description:
+ item.description || context.translate("pages.issued_invoices.detail.empty.no_description"),
+ quantityLabel:
+ item.quantity === null
+ ? null
+ : NumberHelper.formatNumber(item.quantity, Number.isInteger(item.quantity) ? 0 : 2),
+ unitAmountLabel: item.unitAmount === null ? null : formatMoney(issuedInvoice, item.unitAmount),
+ subtotalLabel: formatMoney(issuedInvoice, item.subtotalAmount),
+ discountLabel:
+ item.itemDiscountAmount > 0
+ ? `${formatMoney(issuedInvoice, item.itemDiscountAmount)}${
+ item.itemDiscountPercentage !== null
+ ? ` (${NumberHelper.formatNumber(item.itemDiscountPercentage, 2)}%)`
+ : ""
+ }`
+ : null,
+ taxableAmountLabel: formatMoney(issuedInvoice, item.taxableAmount),
+ taxesLabel: buildItemTaxesLabel(item, context.translate),
+ totalAmountLabel: formatMoney(issuedInvoice, item.totalAmount),
+ }));
+
+ const taxes: IssuedInvoiceDetailTaxBreakdownViewModel[] = breakdown.map((item) => ({
+ ...item,
+ }));
+
+ const totals: IssuedInvoiceDetailTotalsViewModel = {
+ subtotalLabel: formatMoney(issuedInvoice, issuedInvoice.subtotalAmount),
+ itemsDiscountLabel,
+ globalDiscountLabel,
+ discountLabel: formatMoney(issuedInvoice, issuedInvoice.totalDiscountAmount),
+ taxableAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxableAmount),
+ taxAmountLabel: formatMoney(issuedInvoice, issuedInvoice.ivaAmount),
+ taxesAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxesAmount),
+ recAmountLabel: usesRec ? formatMoney(issuedInvoice, issuedInvoice.recAmount) : null,
+ retentionAmountLabel: usesRetention
+ ? formatMoney(issuedInvoice, issuedInvoice.retentionAmount)
+ : null,
+ totalAmountLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount),
+ usesRec,
+ usesRetention,
};
const content: IssuedInvoiceDetailContentViewModel = {
@@ -323,6 +482,9 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
customer,
commercial,
fiscal,
+ items,
+ taxes,
+ totals,
linkedProforma: {
linkedProformaId: issuedInvoice.linkedProformaId,
canOpenLinkedProforma: Boolean(issuedInvoice.linkedProformaId),
diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx
index 2bc5b57e..10d26a43 100644
--- a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx
+++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns.tsx
@@ -33,27 +33,28 @@ export function useIssuedInvoicesGridColumns(
() => [
// Nº
{
- id: "series_invoice_number",
header: ({ column }) => (
),
- cell: ({ row }) => (
+ accessorKey: "invoiceReference",
+ /*cell: ({ row }) => (
{row.original.series}
{row.original.invoiceNumber}
- ),
+ ),*/
enableHiding: false,
- enableSorting: false,
- maxSize: 96,
+ enableSorting: true,
+ /*maxSize: 96,
size: 96,
- minSize: 96,
+ minSize: 96,*/
meta: {
- title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"),
+ title: t("pages.issued_invoices.list.grid_columns.invoice_reference"),
+ cellClassName: "font-mono text-sm font-medium",
},
},
{
diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts
index 71ccfc1d..ae6553ae 100644
--- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts
@@ -70,6 +70,19 @@ const buildDto = (): GetIssuedInvoiceByIdResponseDTO => ({
code: "general",
description: "Régimen general",
},
+ fiscal_classification: {
+ tax_regime_code: "general",
+ tax_regime_label: "Régimen general",
+ operation_qualification_code: "S1",
+ operation_qualification_label: "Sujeta",
+ exemption_reason_code: null,
+ exemption_reason_label: null,
+ },
+ verifactu_policy: {
+ profile_status: "obligated_with_verifactu",
+ uses_verifactu: true,
+ effective_at: "2026-01-01T00:00:00.000Z",
+ },
subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"),
items_discount_amount: MoneyDTOHelper.fromNumber(5, "EUR"),
global_discount_percentage: PercentageDTOHelper.fromNumber(10),
@@ -125,6 +138,8 @@ test("adapta el DTO de detalle a una entidad con nullables y valores numéricos
assert.equal(invoice.paymentMethodName, "Transferencia");
assert.equal(invoice.paymentTerm?.name, "30 días");
assert.equal(invoice.paymentTerm?.dueRules[0]?.dueDays, "30");
+ assert.equal(invoice.fiscalClassification?.operationQualificationCode, "S1");
+ assert.equal(invoice.verifactuPolicy?.usesVerifactu, true);
assert.equal(invoice.globalDiscountPercentage, 10);
assert.equal(invoice.taxableAmount, 85);
assert.equal(invoice.totalAmount, 91);
@@ -133,3 +148,12 @@ test("adapta el DTO de detalle a una entidad con nullables y valores numéricos
assert.equal(invoice.items[0]?.unitAmount, 50);
assert.equal(invoice.items[0]?.retentionPercentage, 15);
});
+
+test("acepta facturas sin bloque verifactu sin inventar estado desde el frontend", () => {
+ const invoice = GetIssuedInvoiceByIdAdapter.fromDto({
+ ...buildDto(),
+ verifactu: null,
+ });
+
+ assert.equal(invoice.verifactu, null);
+});
diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts
index 01ba42f7..c095e131 100644
--- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts
@@ -4,11 +4,13 @@ import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common";
import type { GetIssuedInvoiceByIdResult } from "../api";
import type {
IssuedInvoice,
+ IssuedInvoiceFiscalClassification,
IssuedInvoiceItem,
IssuedInvoicePaymentTermDetail,
IssuedInvoiceRecipientDetail,
IssuedInvoiceStatus,
IssuedInvoiceTaxSummary,
+ IssuedInvoiceVerifactuPolicy,
IssuedInvoiceVerifactuRecord,
VerifactuRecordStatus,
} from "../entities";
@@ -41,6 +43,8 @@ export const GetIssuedInvoiceByIdAdapter = {
paymentTerm: mapPaymentTerm(dto.payment_term),
taxRegimeCode: dto.tax_regime.code,
taxRegimeDescription: dto.tax_regime.description,
+ fiscalClassification: mapFiscalClassification(dto.fiscal_classification),
+ verifactuPolicy: mapVerifactuPolicy(dto.verifactu_policy),
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
itemsDiscountAmount: MoneyDTOHelper.toNumber(dto.items_discount_amount),
globalDiscountPercentage: PercentageDTOHelper.toNumber(dto.global_discount_percentage),
@@ -52,7 +56,7 @@ export const GetIssuedInvoiceByIdAdapter = {
retentionAmount: MoneyDTOHelper.toNumber(dto.retention_amount),
taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount),
totalAmount: MoneyDTOHelper.toNumber(dto.total_amount),
- verifactu: mapVerifactu(dto.verifactu),
+ verifactu: mapVerifactu(dto.verifactu ?? null),
items: dto.items.map(mapItem),
};
},
@@ -140,11 +144,46 @@ const mapPaymentTerm = (
};
const mapVerifactu = (
- dto: GetIssuedInvoiceByIdResponseDTO["verifactu"]
-): IssuedInvoiceVerifactuRecord => {
+ dto: GetIssuedInvoiceByIdResponseDTO["verifactu"] | null
+): IssuedInvoiceVerifactuRecord | null => {
+ if (dto == null) {
+ return null;
+ }
+
return {
status: dto.status as VerifactuRecordStatus,
url: dto.url,
qrCode: dto.qr_code,
};
};
+
+const mapFiscalClassification = (
+ dto: GetIssuedInvoiceByIdResponseDTO["fiscal_classification"]
+): IssuedInvoiceFiscalClassification | null => {
+ if (dto == null) {
+ return null;
+ }
+
+ return {
+ taxRegimeCode: dto.tax_regime_code,
+ taxRegimeLabel: dto.tax_regime_label,
+ operationQualificationCode: dto.operation_qualification_code,
+ operationQualificationLabel: dto.operation_qualification_label,
+ exemptionReasonCode: dto.exemption_reason_code,
+ exemptionReasonLabel: dto.exemption_reason_label,
+ };
+};
+
+const mapVerifactuPolicy = (
+ dto: GetIssuedInvoiceByIdResponseDTO["verifactu_policy"]
+): IssuedInvoiceVerifactuPolicy | null => {
+ if (dto == null) {
+ return null;
+ }
+
+ return {
+ profileStatus: dto.profile_status,
+ usesVerifactu: dto.uses_verifactu,
+ effectiveAt: dto.effective_at,
+ };
+};
diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/api/get-issued-invoice-by-id.api.ts b/modules/customer-invoices/src/web/issued-invoices/shared/api/get-issued-invoice-by-id.api.ts
index ceabbd60..908eab67 100644
--- a/modules/customer-invoices/src/web/issued-invoices/shared/api/get-issued-invoice-by-id.api.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/shared/api/get-issued-invoice-by-id.api.ts
@@ -7,7 +7,9 @@ export interface GetIssuedInvoiceByIdParams {
signal?: AbortSignal;
}
-export type GetIssuedInvoiceByIdResult = GetIssuedInvoiceByIdResponseDTO;
+export type GetIssuedInvoiceByIdResult = Omit & {
+ verifactu: GetIssuedInvoiceByIdResponseDTO["verifactu"] | null;
+};
/**
* Recupera el detalle completo de una factura emitida a partir de su identificador.
diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts
index 2bd7d164..ac5c88ca 100644
--- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts
+++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts
@@ -59,6 +59,25 @@ export interface IssuedInvoiceVerifactuRecord {
qrCode: string | null;
}
+export interface IssuedInvoiceFiscalClassification {
+ taxRegimeCode: string;
+ taxRegimeLabel: string | null;
+ operationQualificationCode: string | null;
+ operationQualificationLabel: string | null;
+ exemptionReasonCode: string | null;
+ exemptionReasonLabel: string | null;
+}
+
+export interface IssuedInvoiceVerifactuPolicy {
+ profileStatus:
+ | "not_configured"
+ | "exempt"
+ | "obligated_without_verifactu"
+ | "obligated_with_verifactu";
+ usesVerifactu: boolean;
+ effectiveAt: string | null;
+}
+
export interface IssuedInvoicePaymentTermDueRule {
dueDays: string;
percentage: {
@@ -103,6 +122,8 @@ export interface IssuedInvoice {
paymentTerm: IssuedInvoicePaymentTermDetail | null;
taxRegimeCode: string;
taxRegimeDescription: string | null;
+ fiscalClassification: IssuedInvoiceFiscalClassification | null;
+ verifactuPolicy: IssuedInvoiceVerifactuPolicy | null;
subtotalAmount: number;
itemsDiscountAmount: number;
globalDiscountPercentage: number;
@@ -114,6 +135,6 @@ export interface IssuedInvoice {
retentionAmount: number;
taxesAmount: number;
totalAmount: number;
- verifactu: IssuedInvoiceVerifactuRecord;
+ verifactu: IssuedInvoiceVerifactuRecord | null;
items: IssuedInvoiceItem[];
}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a2bc9996..42264b4b 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,12 @@ packages:
- modules/*
- apps/*
+allowBuilds:
+ '@parcel/watcher': false
+ bcrypt: false
+ esbuild: false
+ msw: false
+
ignoredBuiltDependencies:
- esbuild