From 9c1f98df0c55f49d128234e348b23bc940f8bb02 Mon Sep 17 00:00:00 2001 From: david Date: Wed, 19 Aug 2026 12:04:31 +0200 Subject: [PATCH] feat: enhance issued invoice detail sections with new components and data structures - Updated GetIssuedInvoiceByIdResult to include verifactu as nullable. - Introduced new fiscal classification and verifactu policy interfaces in issued-invoice.entity.ts. - Added new UI components for displaying issued invoice details, including sections for document, fiscal, items, payment, recipient, summary, taxes, totals, and verifactu. - Implemented skeleton loading state for issued invoice detail page. - Created tests for issued invoice detail page to ensure proper rendering and error handling. - Updated pnpm workspace configuration to disallow builds for specific packages. --- .../src/common/locales/en.json | 62 ++- .../src/common/locales/es.json | 68 +++- ...ssued-invoice-detail-view-models.entity.ts | 74 +++- .../blocks/issued-invoice-general-panel.tsx | 85 +++- .../detail/ui/components/index.ts | 4 +- .../issued-invoice-detail-section.tsx | 38 ++ .../issued-invoice-detail-skeleton.tsx | 46 +++ .../pages/issued-invoice-detail-page.test.tsx | 374 ++++++++++++++++++ .../ui/pages/issued-invoice-detail-page.tsx | 11 +- .../detail/ui/sections/index.ts | 9 + .../issued-invoice-document-section.tsx | 71 ++++ .../issued-invoice-fiscal-section.tsx | 33 ++ .../sections/issued-invoice-items-section.tsx | 73 ++++ .../issued-invoice-payment-section.tsx | 63 +++ .../issued-invoice-recipient-section.tsx | 46 +++ .../issued-invoice-summary-section.tsx | 82 ++++ .../sections/issued-invoice-taxes-section.tsx | 57 +++ .../issued-invoice-totals-section.tsx | 79 ++++ .../issued-invoice-verifactu-section.tsx | 99 +++++ ...-issued-invoice-detail-view-models.test.ts | 43 +- ...build-issued-invoice-detail-view-models.ts | 210 ++++++++-- .../use-issued-invoices-grid-columns.tsx | 17 +- .../get-issued-invoice-by-id.adapter.test.ts | 24 ++ .../get-issued-invoice-by-id.adapter.ts | 45 ++- .../api/get-issued-invoice-by-id.api.ts | 4 +- .../shared/entities/issued-invoice.entity.ts | 23 +- pnpm-workspace.yaml | 6 + 27 files changed, 1668 insertions(+), 78 deletions(-) create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-skeleton.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.test.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/index.ts create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-document-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-fiscal-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-items-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-payment-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-recipient-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-summary-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-taxes-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-totals-section.tsx create mode 100644 modules/customer-invoices/src/web/issued-invoices/detail/ui/sections/issued-invoice-verifactu-section.tsx 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 ( + +
+ + + + + +
+
+ ); +}; 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