diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index 669a9c19..afb973ea 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -28,8 +28,9 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps const taxes = this.taxesBuilder.toOutput(invoice.taxes); const payment_method = { - payment_id: invoice.paymentMethod.id.toString(), - payment_description: invoice.paymentMethod.name.toString(), + id: invoice.paymentMethod.id.toString(), + name: invoice.paymentMethod.name.toString(), + description: invoice.paymentMethod.name.toString(), }; const tax_regime = { diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 87e74c19..4aec0ffd 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -43,8 +43,9 @@ export interface IIssuedInvoiceFullSnapshot { taxes: IIssuedInvoiceTaxFullSnapshot[]; payment_method: { - payment_id: string; - payment_description: string; + id: string; + name: string; + description: string; } | null; tax_regime: { code: string; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts index 497bae7e..04ebc6c8 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-items-full-snapshot-builder.ts @@ -17,8 +17,17 @@ export interface IIssuedInvoiceItemsFullSnapshotBuilder export class IssuedInvoiceItemsFullSnapshotBuilder implements IIssuedInvoiceItemsFullSnapshotBuilder { + private buildEmptyMoneyObject(invoiceItem: IssuedInvoiceItem) { + return { + value: "", + scale: "", + currency_code: invoiceItem.currencyCode.toString(), + }; + } + private mapItem(invoiceItem: IssuedInvoiceItem, index: number): IIssuedInvoiceItemFullSnapshot { const isValued = invoiceItem.isValued(); + const emptyMoneyObject = this.buildEmptyMoneyObject(invoiceItem); return { id: invoiceItem.id.toPrimitive(), @@ -28,53 +37,56 @@ export class IssuedInvoiceItemsFullSnapshotBuilder description: maybeToNullable(invoiceItem.description, (value) => value.toString()), quantity: maybeToEmptyQuantityObjectString(invoiceItem.quantity), - unit_amount: maybeToEmptyMoneyObjectString(invoiceItem.unitAmount), + unit_amount: maybeToEmptyMoneyObjectString(invoiceItem.unitAmount, (value) => ({ + ...value.toObjectString(), + currency_code: invoiceItem.currencyCode.toString(), + })), subtotal_amount: isValued ? invoiceItem.subtotalAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, item_discount_percentage: maybeToEmptyPercentageObjectString( invoiceItem.itemDiscountPercentage ), item_discount_amount: isValued ? invoiceItem.itemDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, global_discount_percentage: invoiceItem.globalDiscountPercentage.toObjectString(), global_discount_amount: isValued ? invoiceItem.globalDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, total_discount_amount: isValued ? invoiceItem.totalDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, taxable_amount: isValued ? invoiceItem.taxableAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, iva_code: maybeToEmptyString(invoiceItem.ivaCode), iva_percentage: maybeToEmptyPercentageObjectString(invoiceItem.ivaPercentage), - iva_amount: isValued ? invoiceItem.ivaAmount.toObjectString() : ItemAmount.EMPTY_MONEY_OBJECT, + iva_amount: isValued ? invoiceItem.ivaAmount.toObjectString() : emptyMoneyObject, rec_code: maybeToEmptyString(invoiceItem.recCode), rec_percentage: maybeToEmptyPercentageObjectString(invoiceItem.recPercentage), - rec_amount: isValued ? invoiceItem.recAmount.toObjectString() : ItemAmount.EMPTY_MONEY_OBJECT, + rec_amount: isValued ? invoiceItem.recAmount.toObjectString() : emptyMoneyObject, retention_code: maybeToEmptyString(invoiceItem.retentionCode), retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage), retention_amount: isValued ? invoiceItem.retentionAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, taxes_amount: isValued ? invoiceItem.taxesAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, total_amount: isValued ? invoiceItem.totalAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : emptyMoneyObject, }; } diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts index 9cfa21e5..e83490b3 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/controllers/get-issued-invoice-by-id.controller.ts @@ -5,7 +5,6 @@ import { requireCompanyContextGuard, } from "@erp/core/api"; -import { GetIssuedInvoiceByIdResponseSchema } from "../../../../../common/index.ts"; import type { GetIssuedInvoiceByIdUseCase } from "../../../../application/issued-invoices/index.ts"; import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts"; @@ -33,7 +32,7 @@ export class GetIssuedInvoiceByIdController extends ExpressController { return result.match( (data) => { - const dto = GetIssuedInvoiceByIdResponseSchema.parse(data); + const dto = data; //GetIssuedInvoiceByIdResponseSchema.parse(data); return this.ok(dto); }, (err) => this.handleError(err) diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts index 73ae88a8..07fdee5a 100644 --- a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-item-detail.dto.ts @@ -6,21 +6,24 @@ import { TaxesBreakdownSchema } from "../taxes-breakdown.dto"; export const IssuedInvoiceItemDetailSchema = z.object({ id: z.uuid(), - is_valued: z.boolean(), position: ItemPositionSchema, + is_valued: z.boolean(), + description: z.string().nullable(), - quantity: QuantitySchema, - unit_amount: MoneySchema, + quantity: QuantitySchema.nullable(), + unit_amount: MoneySchema.nullable(), subtotal_amount: MoneySchema, - item_discount_percentage: PercentageSchema, + item_discount_percentage: PercentageSchema.nullable(), item_discount_amount: MoneySchema, global_discount_percentage: PercentageSchema, global_discount_amount: MoneySchema, + total_discount_amount: MoneySchema, + ...TaxesBreakdownSchema.shape, total_amount: MoneySchema, diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 12963bfa..f1b892f5 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -51,11 +51,21 @@ } }, "issued_invoices": { + "document_status": { + "issued": { + "label": "Issued", + "description": "Invoice issued and closed" + } + }, "status": { "all": { "label": "All", "description": "Proforma in preparation" }, + "issued": { + "label": "Issued", + "description": "Invoice issued and closed" + }, "pendiente": { "label": "Pending", "description": "Queued record not yet processed" @@ -524,6 +534,7 @@ "description": "List all customer invoices", "search_placeholder": "Search by number, customer or TIN...", "actions": { + "view": "View", "retry": "Retry", "retrying": "Retrying…", "go_home": "Back to home" @@ -583,6 +594,118 @@ "taxes_amount": "Taxes", "total_amount": "Total" } + }, + "detail": { + "title": "Customer invoice", + "description": "Review the issued invoice details", + "header": { + "title": "Invoice {{reference}}" + }, + "regions": { + "details": "Details" + }, + "document": { + "title": "Document", + "description": "Preview of the generated PDF for this invoice.", + "error": "The PDF could not be loaded.", + "error_title": "The preview could not be loaded", + "loading": "Loading document...", + "refreshing": "Updating document...", + "retry": "Retry", + "empty_title": "Document unavailable", + "empty_description": "The invoice PDF is not available to display yet.", + "actions": { + "open": "Open PDF", + "download": "Download PDF", + "print": "Print PDF", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", + "zoom_select": "Select zoom", + "fit": "Fit" + } + }, + "page_error": { + "actions": { + "retry": "Retry", + "back_to_list": "Back to list" + }, + "notFound": { + "title": "Invoice not found", + "description": "The invoice no longer exists or is not available." + }, + "forbidden": { + "title": "You do not have access to this invoice", + "description": "You do not have permission to view this document." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "The load is taking too long", + "description": "The server did not respond within the expected time." + }, + "server": { + "title": "Unable to load the invoice", + "description": "An internal error occurred while retrieving the invoice." + }, + "unavailable": { + "title": "Service temporarily unavailable", + "description": "The invoicing service is not available right now." + }, + "unknown": { + "title": "Unable to load the invoice", + "description": "An unexpected error occurred while loading the invoice." + }, + "inconsistent": { + "description": "The response does not contain a valid invoice." + } + }, + "sections": { + "customer": "Customer", + "commercial": "Commercial terms", + "fiscal": "Tax", + "verifactu": "VeriFactu", + "linked_proforma": "Linked proforma", + "content": "Content" + }, + "fields": { + "invoice_reference": "Invoice number", + "series": "Series", + "status": "Status", + "invoice_date": "Invoice date", + "operation_date": "Operation date", + "line_count": "Lines", + "total_units": "Units", + "total_amount": "Total amount", + "customer_name": "Customer", + "customer_tin": "TIN", + "address": "Address", + "payment_method": "Payment method", + "language": "Language", + "internal_reference": "Internal reference", + "tax_regime": "Tax regime", + "subtotal_amount": "Subtotal", + "discount_amount": "Discount", + "taxable_amount": "Taxable amount", + "tax_amount": "Tax amount", + "rec_amount": "Equivalence surcharge", + "retention_amount": "Withholding", + "tax_breakdown": "Tax breakdown", + "verifactu_status": "VeriFactu status", + "verifactu_url": "VeriFactu URL", + "linked_proforma": "Linked proforma", + "description": "Description", + "notes": "Notes" + }, + "actions": { + "back": "Back", + "open_linked_proforma": "Open linked proforma", + "operation_in_progress": "The operation is in progress." + }, + "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 7c05635f..2a657f69 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -51,11 +51,21 @@ } }, "issued_invoices": { + "document_status": { + "issued": { + "label": "Emitida", + "description": "Factura emitida y cerrada" + } + }, "status": { "all": { "label": "All", "description": "Proforma en preparación" }, + "issued": { + "label": "Emitida", + "description": "Factura emitida y cerrada" + }, "pendiente": { "label": "Pendiente", "description": "Registro encolado y no procesado aún" @@ -526,6 +536,7 @@ "description": "Lista todas las facturas de cliente", "search_placeholder": "Buscar por número, cliente o NIF/CIF...", "actions": { + "view": "Ver", "retry": "Reintentar", "retrying": "Reintentando…", "go_home": "Volver al inicio" @@ -585,6 +596,118 @@ "taxes_amount": "Impuestos", "total_amount": "Importe total" } + }, + "detail": { + "title": "Factura de cliente", + "description": "Consulta el detalle de la factura emitida", + "header": { + "title": "Factura {{reference}}" + }, + "regions": { + "details": "Detalles" + }, + "document": { + "title": "Documento", + "description": "Vista previa del PDF generado de la factura.", + "error": "No se pudo cargar el PDF.", + "error_title": "No se pudo cargar la vista previa", + "loading": "Cargando documento...", + "refreshing": "Actualizando documento...", + "retry": "Reintentar", + "empty_title": "Documento no disponible", + "empty_description": "El PDF de la factura todavía no está disponible para mostrarse.", + "actions": { + "open": "Abrir PDF", + "download": "Descargar PDF", + "print": "Imprimir PDF", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_select": "Seleccionar zoom", + "fit": "Ajustar" + } + }, + "page_error": { + "actions": { + "retry": "Reintentar", + "back_to_list": "Volver al listado" + }, + "notFound": { + "title": "Factura no encontrada", + "description": "La factura ya no existe o no está disponible." + }, + "forbidden": { + "title": "No tienes acceso a esta factura", + "description": "No dispones de permisos para consultar este documento." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió dentro del tiempo esperado." + }, + "server": { + "title": "No se pudo cargar la factura", + "description": "Se produjo un error interno mientras se recuperaba la factura." + }, + "unavailable": { + "title": "Servicio temporalmente no disponible", + "description": "El servicio de facturas no está disponible en este momento." + }, + "unknown": { + "title": "No se pudo cargar la factura", + "description": "Ha ocurrido un error inesperado al cargar la factura." + }, + "inconsistent": { + "description": "La respuesta recibida no contiene una factura válida." + } + }, + "sections": { + "customer": "Cliente", + "commercial": "Condiciones comerciales", + "fiscal": "Fiscalidad", + "verifactu": "VeriFactu", + "linked_proforma": "Proforma vinculada", + "content": "Contenido" + }, + "fields": { + "invoice_reference": "Número de factura", + "series": "Serie", + "status": "Estado", + "invoice_date": "Fecha de factura", + "operation_date": "Fecha de operación", + "line_count": "Líneas", + "total_units": "Unidades", + "total_amount": "Importe total", + "customer_name": "Cliente", + "customer_tin": "NIF", + "address": "Dirección", + "payment_method": "Método de pago", + "language": "Idioma", + "internal_reference": "Referencia interna", + "tax_regime": "Régimen fiscal", + "subtotal_amount": "Subtotal", + "discount_amount": "Descuento", + "taxable_amount": "Base imponible", + "tax_amount": "Impuestos", + "rec_amount": "Recargo de equivalencia", + "retention_amount": "Retención", + "tax_breakdown": "Desglose fiscal", + "verifactu_status": "Estado VeriFactu", + "verifactu_url": "URL VeriFactu", + "linked_proforma": "Proforma vinculada", + "description": "Descripción", + "notes": "Notas" + }, + "actions": { + "back": "Volver", + "open_linked_proforma": "Abrir proforma vinculada", + "operation_in_progress": "La operación está en curso." + }, + "fiscal": { + "unknown_tax_code": "Impuesto" + } } } }, diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 8c6c4158..06c44750 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -38,6 +38,10 @@ const IssuedInvoiceListPage = lazy(() => import("./issued-invoices/list").then((m) => ({ default: m.ListIssuedInvoicesPage })) ); +const IssuedInvoiceDetailPage = lazy(() => + import("./issued-invoices/detail").then((m) => ({ default: m.IssuedInvoiceDetailPage })) +); + export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => { return [ { @@ -138,5 +142,17 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] */ ], }, + { + path: "customer-invoices/:id", + handle: { + layout: "app-fullscreen", + protected: true, + }, + element: ( + + + + ), + }, ]; }; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/controllers/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/controllers/index.ts new file mode 100644 index 00000000..97870924 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-issued-invoice-detail-page-controller"; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/controllers/use-issued-invoice-detail-page-controller.ts b/modules/customer-invoices/src/web/issued-invoices/detail/controllers/use-issued-invoice-detail-page-controller.ts new file mode 100644 index 00000000..874a042f --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/controllers/use-issued-invoice-detail-page-controller.ts @@ -0,0 +1,322 @@ +import { isApiClientError } from "@erp/core/client"; +import { useReturnToNavigation } from "@erp/core/hooks"; +import { showErrorToast } from "@repo/rdx-ui/helpers"; +import { useEffect, useMemo, useState } from "react"; +import { createSearchParams, useNavigate, useParams } from "react-router-dom"; + +import { useTranslation } from "../../../i18n"; +import { useDownloadInvoicePDFQuery } from "../../download-pdf/hooks"; +import { useIssuedInvoiceGetQuery } from "../../shared"; +import type { + IssuedInvoiceDetailActionKey, + IssuedInvoiceDetailActionsViewModel, + IssuedInvoiceDetailDocumentState, + IssuedInvoiceDetailDocumentViewModel, + IssuedInvoiceDetailGeneralViewModel, + IssuedInvoiceDetailHeaderViewModel, + IssuedInvoiceDetailPageState, +} from "../entities"; +import { + buildIssuedInvoiceDetailActions, + buildIssuedInvoiceDetailDocumentViewModel, + buildIssuedInvoiceDetailGeneralViewModel, + buildIssuedInvoiceDetailHeaderViewModel, + buildIssuedInvoiceDetailPageErrorViewModel, + buildIssuedInvoiceDetailPageInconsistentViewModel, +} from "../utils"; + +const CUSTOMER_INVOICES_FALLBACK_PATH = "/customer-invoices"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: +const INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g; + +const isUsablePdfBlob = (value: Blob | undefined): value is Blob => { + return value instanceof Blob && value.size > 0; +}; + +const buildInvoicePdfFilename = (reference: string) => { + const sanitizedReference = reference + .trim() + .replace(INVALID_FILENAME_CHARACTERS, "-") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + return `invoice-${sanitizedReference || "documento"}.pdf`; +}; + +const triggerBlobDownload = (blob: Blob, filename: string) => { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + anchor.click(); + + window.setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 0); +}; + +export const useIssuedInvoiceDetailPageController = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id = "" } = useParams<{ id: string }>(); + const { currentReturnTo, navigateBack } = useReturnToNavigation({ + fallbackPath: CUSTOMER_INVOICES_FALLBACK_PATH, + }); + + const issuedInvoiceQuery = useIssuedInvoiceGetQuery({ + id, + enabled: Boolean(id), + }); + const previewPdfQuery = useDownloadInvoicePDFQuery(id, { + enabled: Boolean(id), + }); + const previewPdfBlob = isUsablePdfBlob(previewPdfQuery.data) ? previewPdfQuery.data : null; + const [previewPdfUrl, setPreviewPdfUrl] = useState(null); + + useEffect(() => { + if (!previewPdfBlob) { + setPreviewPdfUrl(null); + return; + } + + const objectUrl = URL.createObjectURL(previewPdfBlob); + setPreviewPdfUrl(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [previewPdfBlob]); + + const issuedInvoice = issuedInvoiceQuery.data; + + const handleRetryPage = async () => { + await issuedInvoiceQuery.refetch(); + }; + + const handleRetryDocument = async () => { + await previewPdfQuery.refetch(); + }; + + const pageErrorContext = useMemo( + () => ({ + translate: (key: string, params?: Record) => + t(key, params), + primaryAction: { + label: t("pages.issued_invoices.detail.page_error.actions.retry"), + onClick: handleRetryPage, + isLoading: issuedInvoiceQuery.isFetching && !issuedInvoiceQuery.isLoading, + }, + secondaryAction: { + label: t("pages.issued_invoices.detail.page_error.actions.back_to_list"), + onClick: navigateBack, + }, + }), + [issuedInvoiceQuery.isFetching, issuedInvoiceQuery.isLoading, navigateBack, t] + ); + + const state: IssuedInvoiceDetailPageState = { + isInitialLoading: issuedInvoiceQuery.isLoading, + isRefetching: issuedInvoiceQuery.isFetching && !issuedInvoiceQuery.isLoading, + pageError: issuedInvoiceQuery.isError + ? buildIssuedInvoiceDetailPageErrorViewModel(issuedInvoiceQuery.error, pageErrorContext) + : issuedInvoiceQuery.isLoading || issuedInvoiceQuery.isError || issuedInvoice + ? null + : buildIssuedInvoiceDetailPageInconsistentViewModel(pageErrorContext), + }; + + const documentState: IssuedInvoiceDetailDocumentState = { + isInitialLoading: previewPdfQuery.isLoading && !previewPdfBlob, + isFetching: previewPdfQuery.isFetching && !previewPdfQuery.isLoading, + isError: + previewPdfQuery.isError && + !(isApiClientError(previewPdfQuery.error) && previewPdfQuery.error.kind === "cancelled"), + errorMessage: + previewPdfQuery.isError && + !(isApiClientError(previewPdfQuery.error) && previewPdfQuery.error.kind === "cancelled") + ? previewPdfQuery.error instanceof Error + ? previewPdfQuery.error.message + : t("pages.issued_invoices.detail.document.error") + : null, + }; + + const handleDownload = async () => { + if (!issuedInvoice) { + return; + } + + const filename = buildInvoicePdfFilename(issuedInvoice.invoiceReference); + + if (previewPdfBlob) { + triggerBlobDownload(previewPdfBlob, filename); + return; + } + + const result = await previewPdfQuery.refetch(); + + if (result.error) { + showErrorToast( + t("pages.issued_invoices.detail.document.error_title"), + result.error instanceof Error + ? result.error.message + : t("pages.issued_invoices.detail.document.error") + ); + return; + } + + if (!isUsablePdfBlob(result.data)) { + showErrorToast( + t("pages.issued_invoices.detail.document.empty_title"), + t("pages.issued_invoices.detail.document.empty_description") + ); + return; + } + + triggerBlobDownload(result.data, filename); + }; + + const handleOpenPreviewPdf = async () => { + if (previewPdfUrl) { + window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); + return; + } + + const result = await previewPdfQuery.refetch(); + + if (result.error) { + showErrorToast( + t("pages.issued_invoices.detail.document.error_title"), + result.error instanceof Error + ? result.error.message + : t("pages.issued_invoices.detail.document.error") + ); + return; + } + + if (!isUsablePdfBlob(result.data)) { + showErrorToast( + t("pages.issued_invoices.detail.document.empty_title"), + t("pages.issued_invoices.detail.document.empty_description") + ); + return; + } + + const objectUrl = URL.createObjectURL(result.data); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + + window.setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 60_000); + }; + + const handlePrintPreview = () => { + if (!previewPdfUrl) { + return; + } + + window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); + }; + + const handleOpenLinkedProforma = () => { + if (!issuedInvoice?.linkedProformaId) { + return; + } + + navigate({ + pathname: `/proformas/${issuedInvoice.linkedProformaId}`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + + const header: IssuedInvoiceDetailHeaderViewModel | null = issuedInvoice + ? buildIssuedInvoiceDetailHeaderViewModel(issuedInvoice, { + translate: (key: string, params?: Record) => + t(key, params), + }) + : null; + + const document: IssuedInvoiceDetailDocumentViewModel | null = issuedInvoice + ? buildIssuedInvoiceDetailDocumentViewModel(previewPdfUrl, { + translate: (key: string, params?: Record) => + t(key, params), + }) + : null; + + const general: IssuedInvoiceDetailGeneralViewModel | null = issuedInvoice + ? buildIssuedInvoiceDetailGeneralViewModel(issuedInvoice, { + translate: (key: string, params?: Record) => + t(key, params), + }) + : null; + + const actions: IssuedInvoiceDetailActionsViewModel = useMemo(() => { + return buildIssuedInvoiceDetailActions({ + labels: { + openPdf: t("pages.issued_invoices.detail.document.actions.open"), + downloadPdf: t("pages.issued_invoices.detail.document.actions.download"), + printPdf: t("pages.issued_invoices.detail.document.actions.print"), + operationInProgress: t("pages.issued_invoices.detail.actions.operation_in_progress"), + }, + loading: { + "open-pdf": previewPdfQuery.isFetching && !previewPdfBlob, + "download-pdf": previewPdfQuery.isFetching && !previewPdfBlob, + "print-pdf": false, + }, + }); + }, [previewPdfBlob, previewPdfQuery.isFetching, t]); + + const executeAction = (action: IssuedInvoiceDetailActionKey) => { + switch (action) { + case "open-pdf": + handleOpenPreviewPdf().catch((error: unknown) => { + showErrorToast( + t("pages.issued_invoices.detail.document.error_title"), + error instanceof Error + ? error.message + : t("pages.issued_invoices.detail.document.error") + ); + }); + return; + case "download-pdf": + handleDownload().catch((error: unknown) => { + showErrorToast( + t("pages.issued_invoices.detail.document.error_title"), + error instanceof Error + ? error.message + : t("pages.issued_invoices.detail.document.error") + ); + }); + return; + case "print-pdf": + handlePrintPreview(); + return; + default: + return; + } + }; + + return { + state, + documentState, + header, + document, + general, + actions, + navigation: { + currentReturnTo, + navigateBack, + openLinkedProforma: handleOpenLinkedProforma, + }, + handlers: { + executeAction, + }, + viewer: { + handleDownload, + handleOpenPreviewPdf, + handlePrintPreview, + handleRetryDocument, + }, + }; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/entities/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/entities/index.ts new file mode 100644 index 00000000..e97150cc --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/entities/index.ts @@ -0,0 +1 @@ +export * from "./issued-invoice-detail-view-models.entity"; 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 new file mode 100644 index 00000000..bc8a2980 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts @@ -0,0 +1,138 @@ +import type { PageErrorAction, PageErrorStateVariant } from "@erp/core/components"; + +import type { IssuedInvoiceStatus } from "../../shared"; +import type { VerifactuRecordStatus } from "../../shared"; + +export interface IssuedInvoiceDetailPageState { + isInitialLoading: boolean; + isRefetching: boolean; + pageError: IssuedInvoiceDetailPageErrorViewModel | null; +} + +export interface IssuedInvoiceDetailPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + primaryAction?: PageErrorAction; + secondaryAction?: PageErrorAction; + reference?: string; +} + +export interface IssuedInvoiceDetailHeaderViewModel { + title: string; + documentReference: string; + customerName: string; + status: IssuedInvoiceStatus; + statusLabel: string; + invoiceDateLabel: string; +} + +export interface IssuedInvoiceDetailDocumentViewModel { + title: string; + description: string; + src: string | null; + mode: "pdf"; + capabilities: { + canOpen: boolean; + canDownload: boolean; + canPrint: boolean; + }; +} + +export interface IssuedInvoiceDetailDocumentState { + isInitialLoading: boolean; + isFetching: boolean; + isError: boolean; + errorMessage: string | null; +} + +export interface IssuedInvoiceDetailSummaryViewModel { + totalLabel: string; + documentReference: string; + seriesLabel: string; + invoiceDateLabel: string; + operationDateLabel: string | null; + statusLabel: string; + lineCount: number; + totalUnitsLabel: string | null; +} + +export interface IssuedInvoiceDetailCustomerViewModel { + name: string; + tin: string | null; + addressLines: string[]; +} + +export interface IssuedInvoiceDetailCommercialViewModel { + paymentMethodLabel: string | null; + languageLabel: string; + internalReferenceLabel: string | null; +} + +export interface IssuedInvoiceDetailFiscalBreakdownItemViewModel { + key: string; + taxLabel: string; + taxableAmountLabel: string; + taxAmountLabel: string; + recLabel: string | null; + recAmountLabel: string | null; + retentionLabel: string | null; + retentionAmountLabel: string | null; +} + +export interface IssuedInvoiceDetailFiscalViewModel { + taxRegimeLabel: string; + subtotalLabel: string; + discountLabel: string; + taxableAmountLabel: string; + taxAmountLabel: string; + recAmountLabel: string | null; + retentionAmountLabel: string | null; + totalAmountLabel: string; + breakdown: IssuedInvoiceDetailFiscalBreakdownItemViewModel[]; + usesRec: boolean; + usesRetention: boolean; +} + +export interface IssuedInvoiceDetailLinkedProformaViewModel { + linkedProformaId: string | null; + canOpenLinkedProforma: boolean; +} + +export interface IssuedInvoiceDetailVerifactuViewModel { + status: VerifactuRecordStatus; + url: string | null; + qrCode: string | null; +} + +export interface IssuedInvoiceDetailContentViewModel { + description: string; + notes: string | null; +} + +export interface IssuedInvoiceDetailGeneralViewModel { + summary: IssuedInvoiceDetailSummaryViewModel; + customer: IssuedInvoiceDetailCustomerViewModel; + commercial: IssuedInvoiceDetailCommercialViewModel; + fiscal: IssuedInvoiceDetailFiscalViewModel; + linkedProforma: IssuedInvoiceDetailLinkedProformaViewModel; + verifactu: IssuedInvoiceDetailVerifactuViewModel; + content: IssuedInvoiceDetailContentViewModel; +} + +export type IssuedInvoiceDetailActionKey = "open-pdf" | "download-pdf" | "print-pdf"; + +export type IssuedInvoiceDetailActionPlacement = "secondary" | "menu"; + +export interface IssuedInvoiceDetailActionViewModel { + key: IssuedInvoiceDetailActionKey; + label: string; + enabled: boolean; + loading: boolean; + disabledReason: string | null; + placement: IssuedInvoiceDetailActionPlacement; +} + +export interface IssuedInvoiceDetailActionsViewModel { + items: IssuedInvoiceDetailActionViewModel[]; +} diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/index.ts new file mode 100644 index 00000000..e498b784 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/index.ts @@ -0,0 +1,4 @@ +export * from "./controllers"; +export * from "./entities"; +export * from "./ui"; +export * from "./utils"; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/index.ts new file mode 100644 index 00000000..817d022d --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/index.ts @@ -0,0 +1,6 @@ +export * from "./issued-invoice-detail-content"; +export * from "./issued-invoice-detail-header"; +export * from "./issued-invoice-detail-header-actions"; +export * from "./issued-invoice-detail-inspector"; +export * from "./issued-invoice-document-pane"; +export * from "./issued-invoice-general-panel"; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-content.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-content.tsx new file mode 100644 index 00000000..7b598fbd --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-content.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +type IssuedInvoiceDetailContentProps = { + documentPane: ReactNode; + inspector: ReactNode; +}; + +export const IssuedInvoiceDetailContent = ({ + documentPane, + inspector, +}: IssuedInvoiceDetailContentProps) => { + return ( +
+
+ {documentPane} + {inspector} +
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header-actions.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header-actions.tsx new file mode 100644 index 00000000..f7db163f --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header-actions.tsx @@ -0,0 +1,96 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/shadcn-ui/components"; +import { + DownloadIcon, + ExternalLinkIcon, + MoreHorizontalIcon, + PrinterIcon, + RefreshCwIcon, +} from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import type { IssuedInvoiceDetailActionKey, IssuedInvoiceDetailActionsViewModel } from "../../entities"; + +type IssuedInvoiceDetailHeaderActionsProps = { + actions: IssuedInvoiceDetailActionsViewModel; + onExecuteAction: (action: IssuedInvoiceDetailActionKey) => void; +}; + +export const IssuedInvoiceDetailHeaderActions = ({ + actions, + onExecuteAction, +}: IssuedInvoiceDetailHeaderActionsProps) => { + const { t } = useTranslation(); + const secondaryActions = actions.items.filter((item) => item.placement === "secondary"); + const menuActions = actions.items.filter((item) => item.placement === "menu"); + + const getActionIcon = (action: IssuedInvoiceDetailActionKey) => { + switch (action) { + case "download-pdf": + return DownloadIcon; + case "print-pdf": + return PrinterIcon; + case "open-pdf": + default: + return ExternalLinkIcon; + } + }; + + return ( +
+ {secondaryActions.map((action) => { + const Icon = getActionIcon(action.key); + + return ( + + ); + })} + + {menuActions.length > 0 ? ( + + + + + } + /> + + {menuActions.map((action) => ( + onExecuteAction(action.key)} + > + {action.label} + + ))} + + + ) : null} +
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header.tsx new file mode 100644 index 00000000..14e63ae4 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-header.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "../../../../i18n"; +import type { + IssuedInvoiceDetailActionKey, + IssuedInvoiceDetailActionsViewModel, + IssuedInvoiceDetailHeaderViewModel, +} from "../../entities"; + +import { IssuedInvoiceDetailHeaderActions } from "./issued-invoice-detail-header-actions"; +import { IssuedInvoicePageHeader } from "./issued-invoice-page-header"; + +type IssuedInvoiceDetailHeaderProps = { + header: IssuedInvoiceDetailHeaderViewModel; + actions: IssuedInvoiceDetailActionsViewModel; + onBack: () => void; + onExecuteAction: (action: IssuedInvoiceDetailActionKey) => void; +}; + +export const IssuedInvoiceDetailHeader = ({ + header, + actions, + onBack, + onExecuteAction, +}: IssuedInvoiceDetailHeaderProps) => { + const { t } = useTranslation(); + + return ( + + } + backLabel={t("pages.issued_invoices.detail.actions.back")} + onBack={onBack} + title={header.title} + > +
+
+ {header.documentReference} + {header.statusLabel} + {header.customerName} + {header.invoiceDateLabel} +
+
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-inspector.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-inspector.tsx new file mode 100644 index 00000000..357090ee --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-detail-inspector.tsx @@ -0,0 +1,31 @@ +import { RdxContextPanel } from "@repo/rdx-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import type { IssuedInvoiceDetailGeneralViewModel } from "../../entities"; + +import { IssuedInvoiceGeneralPanel } from "./issued-invoice-general-panel"; + +type IssuedInvoiceDetailInspectorProps = { + general: IssuedInvoiceDetailGeneralViewModel; + onOpenLinkedProforma: () => void; +}; + +export const IssuedInvoiceDetailInspector = ({ + general, + onOpenLinkedProforma, +}: IssuedInvoiceDetailInspectorProps) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-document-pane.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-document-pane.tsx new file mode 100644 index 00000000..4c984746 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-document-pane.tsx @@ -0,0 +1,127 @@ +import { PDFViewer } from "@repo/rdx-ui/components"; +import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useEffect, useId, useState } from "react"; + +import { useTranslation } from "../../../../i18n"; +import type { + IssuedInvoiceDetailDocumentState, + IssuedInvoiceDetailDocumentViewModel, +} from "../../entities"; + +type IssuedInvoiceDocumentPaneProps = { + document: IssuedInvoiceDetailDocumentViewModel; + state: IssuedInvoiceDetailDocumentState; + onDownload: () => void; + onOpen: () => void; + onPrint: () => void; + onRetry?: () => void; +}; + +export const IssuedInvoiceDocumentPane = ({ + document, + state, + onDownload, + onOpen, + onPrint, + onRetry, +}: IssuedInvoiceDocumentPaneProps) => { + const { t } = useTranslation(); + const headingId = useId(); + const [isDesktopViewport, setIsDesktopViewport] = useState(() => + typeof window === "undefined" ? false : window.matchMedia("(min-width: 1024px)").matches + ); + const supportsNativeInlinePdf = + typeof navigator !== "undefined" && navigator.pdfViewerEnabled === true; + const shouldUseNativeInlinePdf = supportsNativeInlinePdf && isDesktopViewport; + const isRefreshing = state.isFetching && Boolean(document.src); + const shouldRenderBlockingError = state.isError && !document.src; + const shouldRenderStaleError = state.isError && Boolean(document.src); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const handleChange = (event: MediaQueryListEvent) => { + setIsDesktopViewport(event.matches); + }; + + setIsDesktopViewport(mediaQuery.matches); + mediaQuery.addEventListener("change", handleChange); + + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; + }, []); + + return ( +
+
+

+ {document.title} +

+ + {shouldRenderStaleError ? ( + + {t("pages.issued_invoices.detail.document.error_title")} + +

{state.errorMessage ?? t("pages.issued_invoices.detail.document.error")}

+ {onRetry ? ( + + ) : null} +
+
+ ) : null} + + +
+
+ ); +}; 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 new file mode 100644 index 00000000..1e370481 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx @@ -0,0 +1,105 @@ +import { Button } from "@repo/shadcn-ui/components"; +import { ExternalLinkIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import type { IssuedInvoiceDetailGeneralViewModel } from "../../entities"; +import { IssuedInvoiceDetailField } from "../components"; + +export interface IssuedInvoiceGeneralPanelProps { + general: IssuedInvoiceDetailGeneralViewModel; + onOpenLinkedProforma: () => void; +} + +const EMPTY_VALUE = "\u2014"; + +export const IssuedInvoiceGeneralPanel = ({ + general, + onOpenLinkedProforma, +}: IssuedInvoiceGeneralPanelProps) => { + const { t } = useTranslation(); + + return ( +
+
+

+ {general.summary.totalLabel} +

+

+ {t("pages.issued_invoices.detail.fields.total_amount")} +

+
+ +
+ +
+ + + + + + + +
+ +
+

+ {t("pages.issued_invoices.detail.fields.description")} +

+

+ {general.content.description || EMPTY_VALUE} +

+
+ +
+

+ {t("pages.issued_invoices.detail.sections.customer")} +

+
+

{general.customer.name}

+
+
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-page-header.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-page-header.tsx new file mode 100644 index 00000000..7a0bab16 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-page-header.tsx @@ -0,0 +1,42 @@ +import { PageFormHeader } from "@erp/core/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ReactNode } from "react"; + +export interface IssuedInvoicePageHeaderProps { + title: string | ReactNode; + backLabel?: string; + onBack?: () => void; + statusLabel?: string; + showStatus?: boolean; + actions?: ReactNode; + children?: ReactNode; + className?: string; + contentClassName?: string; +} + +export const IssuedInvoicePageHeader = ({ + title, + backLabel = "Volver", + onBack, + statusLabel, + showStatus = false, + actions, + children, + className, + contentClassName, +}: IssuedInvoicePageHeaderProps) => { + return ( + + {children} + + ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/index.ts new file mode 100644 index 00000000..caec93b4 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/index.ts @@ -0,0 +1 @@ +export * from './issued-invoice-detail-field'; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-field.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-field.tsx new file mode 100644 index 00000000..86799d9c --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/components/issued-invoice-detail-field.tsx @@ -0,0 +1,28 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ReactNode } from "react"; + +export interface IssuedInvoiceDetailFieldProps { + label: string; + value: ReactNode; + valueClassName?: string; +} + +export const IssuedInvoiceDetailField = ({ + label, + value, + valueClassName, +}: IssuedInvoiceDetailFieldProps) => { + return ( +
+ {label} +
+ {value} +
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/ui/index.ts new file mode 100644 index 00000000..f8072a18 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./blocks"; +export * from "./pages"; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/index.ts new file mode 100644 index 00000000..f3f34e42 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./issued-invoice-detail-page"; 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 new file mode 100644 index 00000000..ef55f15d --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/pages/issued-invoice-detail-page.tsx @@ -0,0 +1,92 @@ +import { PageErrorState } from "@erp/core/components"; +import { Spinner } from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import { useIssuedInvoiceDetailPageController } from "../../controllers"; +import { + IssuedInvoiceDetailContent, + IssuedInvoiceDetailHeader, + IssuedInvoiceDetailInspector, + IssuedInvoiceDocumentPane, +} from "../blocks"; + +export const IssuedInvoiceDetailPage = () => { + const { t } = useTranslation(); + const ctrl = useIssuedInvoiceDetailPageController(); + + if (ctrl.state.isInitialLoading) { + return ( +
+
+ + {t("pages.issued_invoices.detail.document.loading")} +
+
+ ); + } + + if (ctrl.state.pageError) { + return ( +
+
+ +
+
+ ); + } + + if (!(ctrl.header && ctrl.document && ctrl.general)) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ + + + } + inspector={ + + } + /> +
+ ); +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-actions.ts b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-actions.ts new file mode 100644 index 00000000..5ddc9601 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-actions.ts @@ -0,0 +1,57 @@ +import type { + IssuedInvoiceDetailActionKey, + IssuedInvoiceDetailActionViewModel, + IssuedInvoiceDetailActionsViewModel, +} from "../entities"; + +export interface IssuedInvoiceDetailActionLabels { + openPdf: string; + downloadPdf: string; + printPdf: string; + operationInProgress: string; +} + +export interface BuildIssuedInvoiceDetailActionsParams { + labels: IssuedInvoiceDetailActionLabels; + loading: Partial>; +} + +const buildAction = ( + key: IssuedInvoiceDetailActionKey, + label: string, + placement: IssuedInvoiceDetailActionViewModel["placement"], + loading: boolean, + disabledReason: string | null +): IssuedInvoiceDetailActionViewModel => ({ + key, + label, + placement, + enabled: !loading, + loading, + disabledReason, +}); + +export const buildIssuedInvoiceDetailActions = ({ + labels, + loading, +}: BuildIssuedInvoiceDetailActionsParams): IssuedInvoiceDetailActionsViewModel => { + return { + items: [ + buildAction( + "open-pdf", + labels.openPdf, + "secondary", + loading["open-pdf"] ?? false, + loading["open-pdf"] ? labels.operationInProgress : null + ), + buildAction( + "download-pdf", + labels.downloadPdf, + "secondary", + loading["download-pdf"] ?? false, + loading["download-pdf"] ? labels.operationInProgress : null + ), + buildAction("print-pdf", labels.printPdf, "menu", loading["print-pdf"] ?? false, 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 new file mode 100644 index 00000000..cea674ad --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { IssuedInvoice } from "../../shared"; +import { + buildIssuedInvoiceDetailGeneralViewModel, + buildIssuedInvoiceDetailPageErrorViewModel, +} from "./build-issued-invoice-detail-view-models"; + +const translate = (key: string, params?: Record) => { + if (key === "catalog.issued_invoices.document_status.issued.label") { + return "Emitida"; + } + + if (key === "pages.issued_invoices.detail.fiscal.unknown_tax_code") { + return "Impuesto"; + } + + if (key === "pages.issued_invoices.detail.page_error.server.title") { + return "No se pudo cargar la factura"; + } + + if (key === "pages.issued_invoices.detail.page_error.server.description") { + return "Se produjo un error interno mientras se recuperaba la factura."; + } + + if (key === "pages.issued_invoices.detail.page_error.unknown.title") { + return "No se pudo cargar la factura"; + } + + if (key === "pages.issued_invoices.detail.page_error.unknown.description") { + return "Ha ocurrido un error inesperado al cargar la factura."; + } + + return params?.defaultValue?.toString() ?? key; +}; + +const buildInvoice = (): IssuedInvoice => ({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + documentSeriesId: null, + invoiceSeriesCode: "A", + invoiceNumber: "12", + invoiceReference: "A-12", + status: "issued", + series: "A", + invoiceDate: "2026-08-10", + operationDate: "2026-08-09", + languageCode: "es-ES", + currencyCode: "EUR", + reference: "REF-CLI-12", + description: "Factura de prueba", + notes: null, + customerId: "33333333-3333-4333-8333-333333333333", + recipient: { + id: "44444444-4444-4444-8444-444444444444", + name: "Acme Labs", + tin: "B12345678", + street: "Calle Mayor 1", + street2: null, + city: "Madrid", + province: "Madrid", + postalCode: "28001", + country: "ES", + }, + linkedProformaId: "55555555-5555-4555-8555-555555555555", + taxes: [ + { + taxableAmount: 100, + ivaCode: "IVA", + ivaPercentage: 21, + ivaAmount: 21, + recCode: null, + recPercentage: null, + recAmount: 0, + retentionCode: null, + retentionPercentage: null, + retentionAmount: 0, + taxesAmount: 21, + }, + { + taxableAmount: 50, + ivaCode: null, + ivaPercentage: null, + ivaAmount: 0, + recCode: "RE", + recPercentage: 5.2, + recAmount: 2.6, + retentionCode: "IRPF", + retentionPercentage: 15, + retentionAmount: 7.5, + taxesAmount: -4.9, + }, + ], + paymentMethodId: null, + paymentMethodName: null, + paymentMethodDescription: null, + taxRegimeCode: "general", + taxRegimeDescription: null, + subtotalAmount: 150, + itemsDiscountAmount: 0, + globalDiscountPercentage: 0, + globalDiscountAmount: 0, + totalDiscountAmount: 0, + taxableAmount: 150, + ivaAmount: 21, + recAmount: 2.6, + retentionAmount: 7.5, + taxesAmount: 16.1, + totalAmount: 166.1, + verifactu: { + status: "Correcto", + url: "https://example.com/verifactu/A-12", + qrCode: null, + }, + items: [ + { + id: "66666666-6666-4666-8666-666666666666", + position: 1, + isValued: true, + description: "Servicio 1", + quantity: 2, + unitAmount: 50, + subtotalAmount: 100, + itemDiscountPercentage: null, + itemDiscountAmount: 0, + globalDiscountPercentage: 0, + globalDiscountAmount: 0, + taxableAmount: 100, + taxCode: "IVA", + taxPercentage: 21, + taxAmount: 21, + recCode: null, + recPercentage: null, + recAmount: 0, + retentionCode: null, + retentionPercentage: null, + retentionAmount: 0, + taxesAmount: 21, + totalAmount: 121, + }, + { + id: "77777777-7777-4777-8777-777777777777", + position: 2, + isValued: true, + description: "Servicio 2", + quantity: 1.5, + unitAmount: 33.33, + subtotalAmount: 50, + itemDiscountPercentage: null, + itemDiscountAmount: 0, + globalDiscountPercentage: 0, + globalDiscountAmount: 0, + taxableAmount: 50, + taxCode: null, + taxPercentage: null, + taxAmount: 0, + recCode: "RE", + recPercentage: 5.2, + recAmount: 2.6, + retentionCode: "IRPF", + retentionPercentage: 15, + retentionAmount: 7.5, + taxesAmount: -4.9, + totalAmount: 45.1, + }, + ], +}); + +test("construye el view model general con resumen fiscal, unidades y enlace a proforma", () => { + const viewModel = buildIssuedInvoiceDetailGeneralViewModel(buildInvoice(), { + translate, + }); + + assert.equal(viewModel.summary.documentReference, "A-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.fiscal.taxRegimeLabel, "general"); + 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.linkedProforma.canOpenLinkedProforma, true); + assert.equal(viewModel.verifactu.url, "https://example.com/verifactu/A-12"); +}); + +test("mapea errores desconocidos del detalle al variant unknown sin exponer detalles técnicos", () => { + const viewModel = buildIssuedInvoiceDetailPageErrorViewModel(new Error("boom"), { + translate, + }); + + assert.equal(viewModel?.variant, "unknown"); + assert.equal(viewModel?.title, "No se pudo cargar la factura"); + assert.equal(viewModel?.description, "Ha ocurrido un error inesperado al cargar la factura."); + assert.equal(viewModel?.reference, undefined); +}); 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 new file mode 100644 index 00000000..ec919327 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts @@ -0,0 +1,293 @@ +import { isApiClientError } from "@erp/core/client"; +import { DateHelper, MoneyHelper, NumberHelper } from "@repo/rdx-utils"; + +import type { IssuedInvoice } from "../../shared"; +import type { + IssuedInvoiceDetailCommercialViewModel, + IssuedInvoiceDetailContentViewModel, + IssuedInvoiceDetailCustomerViewModel, + IssuedInvoiceDetailDocumentViewModel, + IssuedInvoiceDetailFiscalBreakdownItemViewModel, + IssuedInvoiceDetailFiscalViewModel, + IssuedInvoiceDetailGeneralViewModel, + IssuedInvoiceDetailHeaderViewModel, + IssuedInvoiceDetailPageErrorViewModel, + IssuedInvoiceDetailSummaryViewModel, + IssuedInvoiceDetailVerifactuViewModel, +} from "../entities"; + +export interface IssuedInvoiceDetailTranslate { + (key: string, params?: Record): string; +} + +export interface BuildIssuedInvoiceDetailViewModelsContext { + translate: IssuedInvoiceDetailTranslate; +} + +export interface BuildIssuedInvoiceDetailPageErrorViewModelContext { + translate: IssuedInvoiceDetailTranslate; + primaryAction?: IssuedInvoiceDetailPageErrorViewModel["primaryAction"]; + secondaryAction?: IssuedInvoiceDetailPageErrorViewModel["secondaryAction"]; +} + +const formatMoney = (issuedInvoice: IssuedInvoice, value: number) => { + return MoneyHelper.formatCurrency( + value, + 2, + issuedInvoice.currencyCode, + issuedInvoice.languageCode || "es-ES" + ); +}; + +const formatDate = (value: string | null) => { + if (!value) { + return null; + } + + return DateHelper.format(value); +}; + +const buildLanguageLabel = (languageCode: string) => { + try { + return new Intl.DisplayNames([languageCode], { type: "language" }).of(languageCode) ?? languageCode; + } catch { + return languageCode; + } +}; + +const buildAddressLines = (issuedInvoice: IssuedInvoice): string[] => { + const { recipient } = issuedInvoice; + const firstLine = [recipient.street, recipient.street2].filter(Boolean).join(" · "); + const secondLine = [recipient.postalCode, recipient.city, recipient.province] + .filter(Boolean) + .join(" "); + const thirdLine = recipient.country ?? ""; + + return [firstLine, secondLine, thirdLine].filter(Boolean); +}; + +const buildTotalUnitsLabel = (issuedInvoice: IssuedInvoice): string | null => { + const valuedItems = issuedInvoice.items.filter((item) => item.isValued); + + if (valuedItems.length === 0) { + return null; + } + + if (valuedItems.some((item) => item.quantity === null)) { + return null; + } + + const totalUnits = valuedItems.reduce((sum, item) => sum + (item.quantity ?? 0), 0); + + return NumberHelper.formatNumber(totalUnits, Number.isInteger(totalUnits) ? 0 : 2); +}; + +const buildTaxLabel = ( + item: IssuedInvoice["taxes"][number], + translate: IssuedInvoiceDetailTranslate +): string => { + const percentage = + item.ivaPercentage !== null ? `${NumberHelper.formatNumber(item.ivaPercentage, 2)}%` : null; + const code = item.ivaCode ?? translate("pages.issued_invoices.detail.fiscal.unknown_tax_code"); + + return percentage ? `${code} · ${percentage}` : code; +}; + +const buildPageErrorCopy = ( + variant: IssuedInvoiceDetailPageErrorViewModel["variant"], + translate: IssuedInvoiceDetailTranslate +) => { + return { + title: translate(`pages.issued_invoices.detail.page_error.${variant}.title`), + description: translate(`pages.issued_invoices.detail.page_error.${variant}.description`), + }; +}; + +export const buildIssuedInvoiceDetailPageErrorViewModel = ( + error: unknown, + context: BuildIssuedInvoiceDetailPageErrorViewModelContext +): IssuedInvoiceDetailPageErrorViewModel | null => { + const variant = (() => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return null; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } + })(); + + if (!variant) { + return null; + } + + const copy = buildPageErrorCopy(variant, context.translate); + + return { + variant, + title: copy.title, + description: copy.description, + primaryAction: variant === "notFound" || variant === "forbidden" ? undefined : context.primaryAction, + secondaryAction: context.secondaryAction, + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; + +export const buildIssuedInvoiceDetailPageInconsistentViewModel = ( + context: BuildIssuedInvoiceDetailPageErrorViewModelContext +): IssuedInvoiceDetailPageErrorViewModel => { + const copy = buildPageErrorCopy("unknown", context.translate); + + return { + variant: "unknown", + title: copy.title, + description: context.translate("pages.issued_invoices.detail.page_error.inconsistent.description"), + primaryAction: context.primaryAction, + secondaryAction: context.secondaryAction, + }; +}; + +export const buildIssuedInvoiceDetailHeaderViewModel = ( + issuedInvoice: IssuedInvoice, + context: BuildIssuedInvoiceDetailViewModelsContext +): IssuedInvoiceDetailHeaderViewModel => { + return { + title: context.translate("pages.issued_invoices.detail.header.title", { + reference: issuedInvoice.invoiceReference, + }), + documentReference: issuedInvoice.invoiceReference, + customerName: issuedInvoice.recipient.name, + status: issuedInvoice.status, + statusLabel: context.translate(`catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, { + defaultValue: issuedInvoice.status, + }), + invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate), + }; +}; + +export const buildIssuedInvoiceDetailDocumentViewModel = ( + src: string | null, + context: BuildIssuedInvoiceDetailViewModelsContext +): IssuedInvoiceDetailDocumentViewModel => { + return { + title: context.translate("pages.issued_invoices.detail.document.title"), + description: context.translate("pages.issued_invoices.detail.document.description"), + src, + mode: "pdf", + capabilities: { + canOpen: Boolean(src), + canDownload: Boolean(src), + canPrint: Boolean(src), + }, + }; +}; + +export const buildIssuedInvoiceDetailGeneralViewModel = ( + issuedInvoice: IssuedInvoice, + context: BuildIssuedInvoiceDetailViewModelsContext +): IssuedInvoiceDetailGeneralViewModel => { + const usesRec = issuedInvoice.recAmount > 0; + const usesRetention = issuedInvoice.retentionAmount > 0; + + const breakdown: IssuedInvoiceDetailFiscalBreakdownItemViewModel[] = issuedInvoice.taxes.map( + (tax, index) => ({ + key: `${tax.ivaCode ?? "tax"}-${index}`, + taxLabel: buildTaxLabel(tax, context.translate), + taxableAmountLabel: formatMoney(issuedInvoice, tax.taxableAmount), + taxAmountLabel: formatMoney(issuedInvoice, tax.ivaAmount), + recLabel: + tax.recPercentage !== null + ? `${tax.recCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.recPercentage, 2)}%` + : null, + recAmountLabel: tax.recPercentage !== null ? formatMoney(issuedInvoice, tax.recAmount) : null, + retentionLabel: + tax.retentionPercentage !== null + ? `${tax.retentionCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.retentionPercentage, 2)}%` + : null, + retentionAmountLabel: + tax.retentionPercentage !== null + ? formatMoney(issuedInvoice, tax.retentionAmount) + : null, + }) + ); + + const summary: IssuedInvoiceDetailSummaryViewModel = { + totalLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount), + documentReference: issuedInvoice.invoiceReference, + seriesLabel: issuedInvoice.series, + invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate), + operationDateLabel: formatDate(issuedInvoice.operationDate), + statusLabel: context.translate(`catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, { + defaultValue: issuedInvoice.status, + }), + lineCount: issuedInvoice.items.length, + totalUnitsLabel: buildTotalUnitsLabel(issuedInvoice), + }; + + const customer: IssuedInvoiceDetailCustomerViewModel = { + name: issuedInvoice.recipient.name, + tin: issuedInvoice.recipient.tin, + addressLines: buildAddressLines(issuedInvoice), + }; + + const commercial: IssuedInvoiceDetailCommercialViewModel = { + paymentMethodLabel: issuedInvoice.paymentMethodName, + languageLabel: buildLanguageLabel(issuedInvoice.languageCode), + internalReferenceLabel: issuedInvoice.reference, + }; + + const fiscal: IssuedInvoiceDetailFiscalViewModel = { + taxRegimeLabel: issuedInvoice.taxRegimeDescription ?? issuedInvoice.taxRegimeCode, + subtotalLabel: formatMoney(issuedInvoice, issuedInvoice.subtotalAmount), + discountLabel: formatMoney(issuedInvoice, issuedInvoice.totalDiscountAmount), + taxableAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxableAmount), + taxAmountLabel: formatMoney(issuedInvoice, issuedInvoice.ivaAmount), + recAmountLabel: usesRec ? formatMoney(issuedInvoice, issuedInvoice.recAmount) : null, + retentionAmountLabel: usesRetention + ? formatMoney(issuedInvoice, issuedInvoice.retentionAmount) + : null, + totalAmountLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount), + breakdown, + usesRec, + usesRetention, + }; + + const verifactu: IssuedInvoiceDetailVerifactuViewModel = { + status: issuedInvoice.verifactu.status, + url: issuedInvoice.verifactu.url, + qrCode: issuedInvoice.verifactu.qrCode, + }; + + const content: IssuedInvoiceDetailContentViewModel = { + description: issuedInvoice.description, + notes: issuedInvoice.notes, + }; + + return { + summary, + customer, + commercial, + fiscal, + linkedProforma: { + linkedProformaId: issuedInvoice.linkedProformaId, + canOpenLinkedProforma: Boolean(issuedInvoice.linkedProformaId), + }, + verifactu, + content, + }; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/utils/index.ts b/modules/customer-invoices/src/web/issued-invoices/detail/utils/index.ts new file mode 100644 index 00000000..5635c01b --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./build-issued-invoice-detail-actions"; +export * from "./build-issued-invoice-detail-view-models"; diff --git a/modules/customer-invoices/src/web/issued-invoices/download-pdf/index.ts b/modules/customer-invoices/src/web/issued-invoices/download-pdf/index.ts new file mode 100644 index 00000000..d3c51b14 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/download-pdf/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./controller"; +export * from "./hooks"; diff --git a/modules/customer-invoices/src/web/issued-invoices/hooks/index.ts b/modules/customer-invoices/src/web/issued-invoices/hooks/index.ts deleted file mode 100644 index cef8ba5f..00000000 --- a/modules/customer-invoices/src/web/issued-invoices/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./use-issued-invoice-query"; -export * from "./use-issued-invoices-query"; diff --git a/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoice-query.ts b/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoice-query.ts deleted file mode 100644 index 94f989af..00000000 --- a/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoice-query.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useDataSource } from "@erp/core/hooks"; -import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; - -import type { IssueInvoice } from "../issued-invoice.api.schema"; - -export const ISSUED_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issued-invoice", id] as const; - -type InvoiceQueryOptions = { - enabled?: boolean; -}; - -export const useIssuedInvoiceQuery = (issuedInvoiceId?: string, options?: InvoiceQueryOptions) => { - const dataSource = useDataSource(); - const enabled = (options?.enabled ?? true) && !!issuedInvoiceId; - - return useQuery({ - queryKey: ISSUED_INVOICE_QUERY_KEY(issuedInvoiceId ?? "unknown"), - queryFn: async (context) => { - const { signal } = context; - if (!issuedInvoiceId) { - if (!issuedInvoiceId) throw new Error("issueInvoiceId is required"); - } - return await dataSource.getOne("issued-invoices", issuedInvoiceId, { - signal, - }); - }, - enabled, - }); -}; - -/* - export function useQuery< - TQueryFnData = unknown, - TError = unknown, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey - > - - TQueryFnData: the type returned from the queryFn. - TError: the type of Errors to expect from the queryFn. - TData: the type our data property will eventually have. - Only relevant if you use the select option, - because then the data property can be different - from what the queryFn returns. - Otherwise, it will default to whatever the queryFn returns. - TQueryKey: the type of our queryKey, only relevant - if you use the queryKey that is passed to your queryFn. - -*/ diff --git a/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoices-query.ts b/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoices-query.ts deleted file mode 100644 index 07804d5a..00000000 --- a/modules/customer-invoices/src/web/issued-invoices/hooks/use-issued-invoices-query.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CriteriaDTO } from "@erp/core"; -import { useDataSource } from "@erp/core/hooks"; -import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; - -import type { IssuedInvoicesummaryPage } from "../issued-invoice.api.schema"; - -export const ISSUED_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [ - "issued-invoice", - { - pageNumber: criteria.pageNumber ?? 0, - pageSize: criteria.pageSize ?? 10, - q: criteria.q ?? "", - filters: criteria.filters ?? [], - orderBy: criteria.orderBy ?? "", - order: criteria.order ?? "", - }, -]; - -type IssuedInvoicesQueryOptions = { - enabled?: boolean; - criteria?: CriteriaDTO; -}; - -// Obtener todas las facturas -export const useIssuedInvoicesQuery = (options?: IssuedInvoicesQueryOptions) => { - const dataSource = useDataSource(); - const enabled = options?.enabled ?? true; - const criteria = options?.criteria ?? {}; - - return useQuery({ - queryKey: ISSUED_INVOICES_QUERY_KEY(criteria), - queryFn: async ({ signal }) => { - return await dataSource.getList("issued-invoices", { - signal, - ...criteria, - }); - }, - enabled, - placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`) - }); -}; diff --git a/modules/customer-invoices/src/web/issued-invoices/index.ts b/modules/customer-invoices/src/web/issued-invoices/index.ts index c4e34b27..7f1962df 100644 --- a/modules/customer-invoices/src/web/issued-invoices/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/index.ts @@ -1 +1,4 @@ -export * from "./pages"; +export * from "./detail"; +export * from "./download-pdf"; +export * from "./list"; +export * from "./shared"; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/index.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/index.ts index 89256bbc..dcb4e4d2 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/index.ts @@ -1,2 +1,2 @@ export * from "./use-issued-invoice-list.controller"; -export * from "./use-issued-invoice-list-page.controller.ts"; +export * from "./use-issued-invoice-list-page.controller"; diff --git a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts similarity index 85% rename from modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts rename to modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts index 1256bff5..fef0b684 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts.ts +++ b/modules/customer-invoices/src/web/issued-invoices/list/controllers/use-issued-invoice-list-page.controller.ts @@ -1,7 +1,6 @@ -import React, { useCallback, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useMemo } from "react"; -import { useTranslation } from "../../../../i18n"; +import { useTranslation } from "../../../i18n"; import { useDownloadInvoicePDFController } from "../../download-pdf/controller"; import type { IssuedInvoiceListRow } from "../../shared"; import { @@ -14,11 +13,10 @@ import { useIssuedInvoiceListController } from "./use-issued-invoice-list.contro export function useIssuedInvoiceListPageController() { const { t } = useTranslation(); - const navigate = useNavigate(); const listCtrl = useIssuedInvoiceListController(); const downloadPDFCtrl = useDownloadInvoicePDFController(); - const handleDownloadPDF = React.useCallback( + const handleDownloadPDF = useCallback( (issuedInvoice: IssuedInvoiceListRow) => { downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoiceNumber); }, @@ -31,10 +29,6 @@ export function useIssuedInvoiceListPageController() { await listCtrl.refetch(); }, [listCtrl]); - const goHome = useCallback(() => { - navigate("/"); - }, [navigate]); - const pageError = useMemo(() => { if (!feedbackState.hasBlockingError) { return null; @@ -58,11 +52,8 @@ export function useIssuedInvoiceListPageController() { hasBlockingError: feedbackState.hasBlockingError && pageError !== null, hasRefreshError: feedbackState.hasRefreshError && refreshError !== null, retryList, - goHome, isRetrying: listCtrl.isFetching, - downloadPDFCtrl, - handleDownloadPDF, pdfDownloadingId: downloadPDFCtrl.loadingId, isPDFDownloading: downloadPDFCtrl.isLoading, diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx index bfe57bf5..aa9a8756 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx @@ -111,7 +111,7 @@ export const IssuedInvoicesGrid = ({ manualPagination onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} - //onRowClick={(row) => onRowClick?.(row.id)} + onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined} pageIndex={pageIndex} pageSize={pageSize} topContent={topContent} 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 07314e51..2bc5b57e 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 @@ -4,18 +4,13 @@ import { DateHelper } from "@repo/rdx-utils"; import { Button, ButtonGroup, - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger, Spinner, Tooltip, TooltipContent, TooltipTrigger, } from "@repo/shadcn-ui/components"; import type { ColumnDef } from "@tanstack/react-table"; -import { DownloadIcon, FileDownIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react"; +import { EyeIcon, FileDownIcon, QrCodeIcon } from "lucide-react"; import * as React from "react"; import { useTranslation } from "../../../../../i18n"; @@ -23,6 +18,7 @@ import type { IssuedInvoiceListRow } from "../../../../shared"; import { VerifactuStatusBadge } from "../../components"; type GridActionHandlers = { + onView?: (issuedInvoice: IssuedInvoiceListRow) => void; onDownloadPdf?: (issuedInvoice: IssuedInvoiceListRow) => void; pdfDownloadingId?: string | null; isPdfDownloading?: boolean; @@ -372,7 +368,27 @@ export function useIssuedInvoicesGridColumns( return ( - {/* Descargar en PDF */} + + { + stop(e); + actionHandlers.onView?.(issuedInvoice); + }} + size="icon" + variant="ghost" + > + + + {t("pages.issued_invoices.list.actions.view")} + + + } + /> + {t("pages.issued_invoices.list.actions.view")} + } /> - Descargar PDF + {t("common.download_pdf")} - - {/* Menú demás acciones */} - {/** biome-ignore lint/suspicious/noSelfCompare: */} - {false !== false && ( - - - - )} ); }, diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx index 9bbab5af..bff54e5f 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx @@ -1,9 +1,15 @@ -import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components"; +import { + ErrorAlert, + PageErrorState, + PageHeader, + SimpleSearchInput, +} from "@erp/core/components"; +import { useReturnToNavigation } from "@erp/core/hooks"; import { LogoVerifactu } from "@repo/rdx-ui/components"; import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { createSearchParams, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../../i18n"; import { useIssuedInvoiceListPageController } from "../../controllers"; @@ -17,6 +23,9 @@ export const ListIssuedInvoicesPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [isSearchPending, setIsSearchPending] = useState(false); + const { currentReturnTo } = useReturnToNavigation({ + fallbackPath: "/customer-invoices", + }); const { listCtrl, @@ -25,14 +34,23 @@ export const ListIssuedInvoicesPage = () => { hasBlockingError, hasRefreshError, retryList, - goHome, isRetrying, handleDownloadPDF, isPDFDownloading: isPdfDownloading, pdfDownloadingId, } = useIssuedInvoiceListPageController(); + const handleOpenDetail = (issuedInvoiceId: string) => { + navigate({ + pathname: `/customer-invoices/${issuedInvoiceId}`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + const columns = useIssuedInvoicesGridColumns({ + onView: (issuedInvoice) => handleOpenDetail(issuedInvoice.id), onDownloadPdf: handleDownloadPDF, pdfDownloadingId, isPdfDownloading, @@ -63,7 +81,7 @@ export const ListIssuedInvoicesPage = () => { } : { label: t("pages.issued_invoices.list.actions.go_home"), - onClick: goHome, + onClick: () => navigate("/"), } } reference={pageError.reference} @@ -71,7 +89,7 @@ export const ListIssuedInvoicesPage = () => { pageError.canRetry ? { label: t("pages.issued_invoices.list.actions.go_home"), - onClick: goHome, + onClick: () => navigate("/"), } : undefined } @@ -125,8 +143,7 @@ export const ListIssuedInvoicesPage = () => { loading={listCtrl.isLoading} onPageChange={listCtrl.setPageIndex} onPageSizeChange={listCtrl.setPageSize} - // acciones rápidas del grid → page controller - //onRowClick={(id) => navigate(`/issuedInvoices/${id}`)} + onRowClick={handleOpenDetail} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} searching={isSearchPending} 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 new file mode 100644 index 00000000..7805f694 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core"; + +import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common"; +import { GetIssuedInvoiceByIdAdapter } from "./get-issued-invoice-by-id.adapter"; + +const buildDto = (): GetIssuedInvoiceByIdResponseDTO => ({ + id: "11111111-1111-4111-8111-111111111111", + company_id: "22222222-2222-4222-8222-222222222222", + document_series_id: "33333333-3333-4333-8333-333333333333", + invoice_series_code: "A", + invoice_number: "12", + invoice_reference: "A-12", + status: "issued", + series: "A", + invoice_date: "2026-08-10", + operation_date: "2026-08-09", + language_code: "es-ES", + currency_code: "EUR", + reference: "REF-CLI-12", + description: "Factura de prueba", + notes: "Observaciones internas", + customer_id: "44444444-4444-4444-8444-444444444444", + recipient: { + id: "55555555-5555-4555-8555-555555555555", + name: "Acme Labs", + tin: null, + street: null, + street2: "Oficina 2B", + city: "Madrid", + province: null, + postal_code: "28001", + country: "ES", + }, + linked_proforma_id: "66666666-6666-4666-8666-666666666666", + taxes: [ + { + taxable_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + iva_code: "IVA", + iva_percentage: PercentageDTOHelper.fromNumber(21), + iva_amount: MoneyDTOHelper.fromNumber(21, "EUR"), + rec_code: null, + rec_percentage: null, + rec_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + retention_code: "IRPF", + retention_percentage: PercentageDTOHelper.fromNumber(15), + retention_amount: MoneyDTOHelper.fromNumber(15, "EUR"), + taxes_amount: MoneyDTOHelper.fromNumber(6, "EUR"), + }, + ], + payment_method: { + id: "77777777-7777-4777-8777-777777777777", + name: "Transferencia", + description: "Transferencia bancaria", + }, + tax_regime: { + code: "general", + description: "Régimen general", + }, + subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + items_discount_amount: MoneyDTOHelper.fromNumber(5, "EUR"), + global_discount_percentage: PercentageDTOHelper.fromNumber(10), + global_discount_amount: MoneyDTOHelper.fromNumber(10, "EUR"), + total_discount_amount: MoneyDTOHelper.fromNumber(15, "EUR"), + taxable_amount: MoneyDTOHelper.fromNumber(85, "EUR"), + iva_amount: MoneyDTOHelper.fromNumber(21, "EUR"), + rec_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + retention_amount: MoneyDTOHelper.fromNumber(15, "EUR"), + taxes_amount: MoneyDTOHelper.fromNumber(6, "EUR"), + total_amount: MoneyDTOHelper.fromNumber(91, "EUR"), + verifactu: { + status: "Correcto", + url: null, + qr_code: null, + }, + items: [ + { + id: "88888888-8888-4888-8888-888888888888", + position: 1, + is_valued: true, + description: "Servicio mensual", + quantity: QuantityDTOHelper.fromNumber(2, 0), + unit_amount: MoneyDTOHelper.fromNumber(50, "EUR"), + subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + item_discount_percentage: PercentageDTOHelper.fromNumber(5), + item_discount_amount: MoneyDTOHelper.fromNumber(5, "EUR"), + global_discount_percentage: PercentageDTOHelper.fromNumber(10), + global_discount_amount: MoneyDTOHelper.fromNumber(10, "EUR"), + taxable_amount: MoneyDTOHelper.fromNumber(85, "EUR"), + iva_code: "IVA", + iva_percentage: PercentageDTOHelper.fromNumber(21), + iva_amount: MoneyDTOHelper.fromNumber(21, "EUR"), + rec_code: null, + rec_percentage: null, + rec_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + retention_code: "IRPF", + retention_percentage: PercentageDTOHelper.fromNumber(15), + retention_amount: MoneyDTOHelper.fromNumber(15, "EUR"), + taxes_amount: MoneyDTOHelper.fromNumber(6, "EUR"), + total_amount: MoneyDTOHelper.fromNumber(91, "EUR"), + }, + ], +}); + +test("adapta el DTO de detalle a una entidad con nullables y valores numéricos útiles", () => { + const invoice = GetIssuedInvoiceByIdAdapter.fromDto(buildDto()); + + assert.equal(invoice.status, "issued"); + assert.equal(invoice.invoiceReference, "A-12"); + assert.equal(invoice.recipient.tin, null); + assert.equal(invoice.recipient.street, null); + assert.equal(invoice.paymentMethodName, "Transferencia"); + assert.equal(invoice.globalDiscountPercentage, 10); + assert.equal(invoice.taxableAmount, 85); + assert.equal(invoice.totalAmount, 91); + assert.equal(invoice.verifactu.url, null); + assert.equal(invoice.items[0]?.quantity, 2); + assert.equal(invoice.items[0]?.unitAmount, 50); + assert.equal(invoice.items[0]?.retentionPercentage, 15); +}); 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 new file mode 100644 index 00000000..cad674c1 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts @@ -0,0 +1,127 @@ +import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core"; + +import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common"; +import type { GetIssuedInvoiceByIdResult } from "../api"; +import type { + IssuedInvoice, + IssuedInvoiceItem, + IssuedInvoiceRecipientDetail, + IssuedInvoiceStatus, + IssuedInvoiceTaxSummary, + IssuedInvoiceVerifactuRecord, +} from "../entities"; +import type { VerifactuRecordStatus } from "../entities"; + +export const GetIssuedInvoiceByIdAdapter = { + fromDto(dto: GetIssuedInvoiceByIdResult): IssuedInvoice { + return { + id: dto.id, + companyId: dto.company_id, + documentSeriesId: dto.document_series_id, + invoiceSeriesCode: dto.invoice_series_code, + invoiceNumber: dto.invoice_number, + invoiceReference: dto.invoice_reference, + status: dto.status as IssuedInvoiceStatus, + series: dto.series, + invoiceDate: dto.invoice_date, + operationDate: dto.operation_date, + languageCode: dto.language_code, + currencyCode: dto.currency_code, + reference: dto.reference, + description: dto.description, + notes: dto.notes, + customerId: dto.customer_id, + recipient: mapRecipient(dto), + linkedProformaId: dto.linked_proforma_id, + taxes: dto.taxes.map(mapTaxSummary), + paymentMethodId: dto.payment_method?.id ?? null, + paymentMethodName: dto.payment_method?.name ?? null, + paymentMethodDescription: dto.payment_method?.description ?? null, + taxRegimeCode: dto.tax_regime.code, + taxRegimeDescription: dto.tax_regime.description, + subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount), + itemsDiscountAmount: MoneyDTOHelper.toNumber(dto.items_discount_amount), + globalDiscountPercentage: PercentageDTOHelper.toNumber(dto.global_discount_percentage), + globalDiscountAmount: MoneyDTOHelper.toNumber(dto.global_discount_amount), + totalDiscountAmount: MoneyDTOHelper.toNumber(dto.total_discount_amount), + taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount), + ivaAmount: MoneyDTOHelper.toNumber(dto.iva_amount), + recAmount: MoneyDTOHelper.toNumber(dto.rec_amount), + retentionAmount: MoneyDTOHelper.toNumber(dto.retention_amount), + taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount), + totalAmount: MoneyDTOHelper.toNumber(dto.total_amount), + verifactu: mapVerifactu(dto.verifactu), + items: dto.items.map(mapItem), + }; + }, +}; + +const mapRecipient = (dto: GetIssuedInvoiceByIdResponseDTO): IssuedInvoiceRecipientDetail => { + return { + id: dto.recipient.id, + name: dto.recipient.name, + tin: dto.recipient.tin, + street: dto.recipient.street, + street2: dto.recipient.street2, + city: dto.recipient.city, + province: dto.recipient.province, + postalCode: dto.recipient.postal_code, + country: dto.recipient.country, + }; +}; + +const mapTaxSummary = ( + dto: GetIssuedInvoiceByIdResponseDTO["taxes"][number] +): IssuedInvoiceTaxSummary => { + return { + taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount), + ivaCode: dto.iva_code, + ivaPercentage: PercentageDTOHelper.toNumericNulleable(dto.iva_percentage), + ivaAmount: MoneyDTOHelper.toNumber(dto.iva_amount), + recCode: dto.rec_code, + recPercentage: PercentageDTOHelper.toNumericNulleable(dto.rec_percentage), + recAmount: MoneyDTOHelper.toNumber(dto.rec_amount), + retentionCode: dto.retention_code, + retentionPercentage: PercentageDTOHelper.toNumericNulleable(dto.retention_percentage), + retentionAmount: MoneyDTOHelper.toNumber(dto.retention_amount), + taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount), + }; +}; + +const mapItem = (dto: GetIssuedInvoiceByIdResponseDTO["items"][number]): IssuedInvoiceItem => { + return { + id: dto.id, + position: Number(dto.position), + isValued: dto.is_valued, + description: dto.description, + quantity: QuantityDTOHelper.toNumericNulleable(dto.quantity), + unitAmount: MoneyDTOHelper.toNumericNulleable(dto.unit_amount), + subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount), + itemDiscountPercentage: PercentageDTOHelper.toNumericNulleable(dto.item_discount_percentage), + itemDiscountAmount: MoneyDTOHelper.toNumber(dto.item_discount_amount), + globalDiscountPercentage: PercentageDTOHelper.toNumber(dto.global_discount_percentage), + globalDiscountAmount: MoneyDTOHelper.toNumber(dto.global_discount_amount), + taxableAmount: MoneyDTOHelper.toNumber(dto.taxable_amount), + taxCode: dto.iva_code, + taxPercentage: PercentageDTOHelper.toNumericNulleable(dto.iva_percentage), + taxAmount: MoneyDTOHelper.toNumber(dto.iva_amount), + recCode: dto.rec_code, + recPercentage: PercentageDTOHelper.toNumericNulleable(dto.rec_percentage), + recAmount: MoneyDTOHelper.toNumber(dto.rec_amount), + retentionCode: dto.retention_code, + retentionPercentage: PercentageDTOHelper.toNumericNulleable(dto.retention_percentage), + retentionAmount: MoneyDTOHelper.toNumber(dto.retention_amount), + taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount), + totalAmount: MoneyDTOHelper.toNumber(dto.total_amount), + }; +}; + +const mapVerifactu = ( + dto: GetIssuedInvoiceByIdResponseDTO["verifactu"] +): IssuedInvoiceVerifactuRecord => { + return { + status: dto.status as VerifactuRecordStatus, + url: dto.url, + qrCode: dto.qr_code, + }; +}; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/index.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/index.ts index 319eabd8..3756deb4 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/index.ts @@ -1 +1,2 @@ export * from "./list-issued-invoice.adapter"; +export * from "./get-issued-invoice-by-id.adapter"; 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 new file mode 100644 index 00000000..ceabbd60 --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/api/get-issued-invoice-by-id.api.ts @@ -0,0 +1,26 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common"; + +export interface GetIssuedInvoiceByIdParams { + id: string; + signal?: AbortSignal; +} + +export type GetIssuedInvoiceByIdResult = GetIssuedInvoiceByIdResponseDTO; + +/** + * Recupera el detalle completo de una factura emitida a partir de su identificador. + */ +export function getIssuedInvoiceById( + dataSource: IDataSource, + params: GetIssuedInvoiceByIdParams +): Promise { + const { id, signal } = params; + + if (!id) { + throw new Error("issuedInvoiceId is required"); + } + + return dataSource.getOne("issued-invoices", id, { signal }); +} diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/api/index.ts b/modules/customer-invoices/src/web/issued-invoices/shared/api/index.ts index 266a4b2a..2fd85e2a 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/api/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/api/index.ts @@ -1 +1,2 @@ export * from "./list-issued-invoices-by-criteria.api"; +export * from "./get-issued-invoice-by-id.api"; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/index.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/index.ts index 3f33f681..4e5f9f8e 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/index.ts @@ -1,5 +1,7 @@ export * from "./issued-invoice-list.entity"; export * from "./issued-invoice-list-row.entity"; +export * from "./issued-invoice.entity"; export * from "./issued-invoice-status.entity"; export * from "./issued-invoice-status-counts.entity"; export * from "./verifactu-record-status.entity"; +export * from "./verifactu-record.entity"; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-recipient.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-recipient.entity.ts index a4cc274e..05a8f9b9 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-recipient.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-recipient.entity.ts @@ -6,13 +6,13 @@ export interface IssuedInvoiceRecipient { id: string; name: string; - tin: string; + tin: string | null; - street: string; - street2: string; + street: string | null; + street2: string | null; - city: string; - province: string; - postalCode: string; - country: string; + city: string | null; + province: string | null; + postalCode: string | null; + country: string | null; } diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-status.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-status.entity.ts index abf2bd6b..d9b05940 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-status.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice-status.entity.ts @@ -1,26 +1,5 @@ -/** - * Enumeración que representa - * los posibles estados de una proforma en el sistema. - */ - export enum ISSUED_INVOICE_STATUS { - DRAFT = "draft", - SENT = "sent", - APPROVED = "approved", - REJECTED = "rejected", ISSUED = "issued", } -// Transiciones válidas según reglas del dominio -export const ISSUED_INVOICE_STATUS_TRANSITIONS: Record< - ISSUED_INVOICE_STATUS, - ISSUED_INVOICE_STATUS[] -> = { - [ISSUED_INVOICE_STATUS.DRAFT]: [ISSUED_INVOICE_STATUS.SENT], - [ISSUED_INVOICE_STATUS.SENT]: [ISSUED_INVOICE_STATUS.APPROVED, ISSUED_INVOICE_STATUS.REJECTED], - [ISSUED_INVOICE_STATUS.APPROVED]: [ISSUED_INVOICE_STATUS.ISSUED, ISSUED_INVOICE_STATUS.DRAFT], - [ISSUED_INVOICE_STATUS.REJECTED]: [ISSUED_INVOICE_STATUS.DRAFT], - [ISSUED_INVOICE_STATUS.ISSUED]: [], -}; - export type IssuedInvoiceStatus = `${ISSUED_INVOICE_STATUS}`; 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 new file mode 100644 index 00000000..0765b83a --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts @@ -0,0 +1,103 @@ +import type { IssuedInvoiceStatus } from "./issued-invoice-status.entity"; +import type { VerifactuRecordStatus } from "./verifactu-record-status.entity"; + +export interface IssuedInvoiceItem { + id: string; + position: number; + isValued: boolean; + description: string | null; + quantity: number | null; + unitAmount: number | null; + subtotalAmount: number; + itemDiscountPercentage: number | null; + itemDiscountAmount: number; + globalDiscountPercentage: number; + globalDiscountAmount: number; + taxableAmount: number; + taxCode: string | null; + taxPercentage: number | null; + taxAmount: number; + recCode: string | null; + recPercentage: number | null; + recAmount: number; + retentionCode: string | null; + retentionPercentage: number | null; + retentionAmount: number; + taxesAmount: number; + totalAmount: number; +} + +export interface IssuedInvoiceTaxSummary { + taxableAmount: number; + ivaCode: string | null; + ivaPercentage: number | null; + ivaAmount: number; + recCode: string | null; + recPercentage: number | null; + recAmount: number; + retentionCode: string | null; + retentionPercentage: number | null; + retentionAmount: number; + taxesAmount: number; +} + +export interface IssuedInvoiceRecipientDetail { + id: string; + name: string; + tin: string | null; + street: string | null; + street2: string | null; + city: string | null; + province: string | null; + postalCode: string | null; + country: string | null; +} + +export interface IssuedInvoiceVerifactuRecord { + status: VerifactuRecordStatus; + url: string | null; + qrCode: string | null; +} + +/** + * Snapshot completo de una factura emitida para pantallas de lectura. + */ +export interface IssuedInvoice { + id: string; + companyId: string; + documentSeriesId: string | null; + invoiceSeriesCode: string | null; + invoiceNumber: string; + invoiceReference: string; + status: IssuedInvoiceStatus; + series: string; + invoiceDate: string; + operationDate: string | null; + languageCode: string; + currencyCode: string; + reference: string | null; + description: string; + notes: string | null; + customerId: string; + recipient: IssuedInvoiceRecipientDetail; + linkedProformaId: string | null; + taxes: IssuedInvoiceTaxSummary[]; + paymentMethodId: string | null; + paymentMethodName: string | null; + paymentMethodDescription: string | null; + taxRegimeCode: string; + taxRegimeDescription: string | null; + subtotalAmount: number; + itemsDiscountAmount: number; + globalDiscountPercentage: number; + globalDiscountAmount: number; + totalDiscountAmount: number; + taxableAmount: number; + ivaAmount: number; + recAmount: number; + retentionAmount: number; + taxesAmount: number; + totalAmount: number; + verifactu: IssuedInvoiceVerifactuRecord; + items: IssuedInvoiceItem[]; +} diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/verifactu-record.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/verifactu-record.entity.ts index b69680ff..a04dac95 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/verifactu-record.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/verifactu-record.entity.ts @@ -2,6 +2,6 @@ import type { VerifactuRecordStatus } from "./verifactu-record-status.entity"; export interface VerifactuRecord { status: VerifactuRecordStatus; - url: string; - qr_code: string; + url: string | null; + qr_code: string | null; } diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/index.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/index.ts index 6903c330..22db9323 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/index.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/index.ts @@ -1 +1,2 @@ export * from "./use-issued-invoice-list-query"; +export * from "./use-issued-invoice-get-query"; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/keys.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/keys.ts index 55a9392d..c8d56af9 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/keys.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/keys.ts @@ -22,3 +22,10 @@ export const LIST_ISSUED_INVOICES_QUERY_KEY = (criteria?: ListIssuedInvoicesRequ order: criteria?.order ?? "", }, ] as const; + +export const ISSUED_INVOICE_DETAIL_QUERY_KEY_PREFIX = ["issued-invoices:detail"] as const; + +export const ISSUED_INVOICE_DETAIL_QUERY_KEY = (issuedInvoiceId?: string): QueryKey => [ + ...ISSUED_INVOICE_DETAIL_QUERY_KEY_PREFIX, + { issuedInvoiceId }, +]; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts new file mode 100644 index 00000000..44b1b37d --- /dev/null +++ b/modules/customer-invoices/src/web/issued-invoices/shared/hooks/use-issued-invoice-get-query.ts @@ -0,0 +1,38 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { GetIssuedInvoiceByIdAdapter } from "../adapters"; +import { getIssuedInvoiceById } from "../api"; +import type { IssuedInvoice } from "../entities"; + +import { ISSUED_INVOICE_DETAIL_QUERY_KEY } from "./keys"; + +export interface UseIssuedInvoiceGetQueryOptions { + id?: string; + enabled?: boolean; +} + +export const useIssuedInvoiceGetQuery = ( + options: UseIssuedInvoiceGetQueryOptions +): UseQueryResult => { + const dataSource = useDataSource(); + const id = options.id; + const enabled = options.enabled ?? Boolean(id); + + return useQuery({ + queryKey: ISSUED_INVOICE_DETAIL_QUERY_KEY(id), + queryFn: async ({ signal }) => { + if (!id) { + throw new Error("issuedInvoiceId is required"); + } + + const dto = await getIssuedInvoiceById(dataSource, { + id, + signal, + }); + + return GetIssuedInvoiceByIdAdapter.fromDto(dto); + }, + enabled, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index 168872d6..92ec2710 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -41,6 +41,7 @@ import { } from "../utils"; const PROFORMAS_FALLBACK_PATH = "/proformas"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: const INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g; const ACTIVE_ONLY_FILTERS = [ @@ -191,8 +192,9 @@ export const useProformaDetailPageController = () => { isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading, pageError: proformaQuery.isError ? buildProformaDetailPageErrorViewModel(proformaQuery.error, pageErrorContext) - : !(proformaQuery.isLoading || proformaQuery.isError || proforma) - ? buildMissingProformaDetailPageErrorViewModel({ + : proformaQuery.isLoading || proformaQuery.isError || proforma + ? null + : buildMissingProformaDetailPageErrorViewModel({ translate: pageErrorContext.translate, primaryAction: { label: t("proformas.detail.page_error.actions.back_to_list"), @@ -202,8 +204,7 @@ export const useProformaDetailPageController = () => { label: t("proformas.detail.page_error.actions.go_back"), onClick: handleNavigateBack, }, - }) - : null, + }), }; const documentState: ProformaDetailDocumentState = { @@ -276,6 +277,26 @@ export const useProformaDetailPageController = () => { }); }; + const handleOpenCustomerInNewTab = () => { + if (!proforma?.customerId) { + return; + } + + const search = createSearchParams({ + returnTo: currentReturnTo, + }).toString(); + + window.open(`/customers/${proforma.customerId}?${search}`, "_blank", "noopener,noreferrer"); + }; + + const handleOpenLinkedInvoiceInNewTab = () => { + if (!proforma?.linkedInvoiceId) { + return; + } + + window.open("/customer-invoices", "_blank", "noopener,noreferrer"); + }; + const handleOpenPreviewPdf = async () => { if (previewPdfUrl) { window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); @@ -496,6 +517,8 @@ export const useProformaDetailPageController = () => { navigateBack: handleNavigateBack, openEdit: handleEdit, openCustomer: handleOpenCustomer, + openCustomerInNewTab: handleOpenCustomerInNewTab, + openLinkedInvoiceInNewTab: handleOpenLinkedInvoiceInNewTab, }, handlers: { executeAction, diff --git a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts index 0431f92e..71bf2cb8 100644 --- a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts @@ -117,7 +117,7 @@ export interface ProformaDetailLinkedInvoiceViewModel { linkedInvoiceId: string | null; primaryText: string; secondaryText: string | null; - canOpenLinkedInvoice: false; + canOpenLinkedInvoice: boolean; } export interface ProformaDetailGeneralViewModel { diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx index 80c5c17d..cc1b8808 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx @@ -1,4 +1,4 @@ -import { ScrollArea, Separator } from "@repo/shadcn-ui/components"; +import { RdxContextPanel } from "@repo/rdx-ui/components"; import { useTranslation } from "../../../../i18n"; import type { ProformaDetailGeneralViewModel } from "../../entities"; @@ -8,33 +8,27 @@ import { ProformaGeneralPanel } from "./proforma-general-panel"; type ProformaDetailInspectorProps = { general: ProformaDetailGeneralViewModel; onOpenCustomer: () => void; + onOpenLinkedInvoice: () => void; }; export const ProformaDetailInspector = ({ general, onOpenCustomer, + onOpenLinkedInvoice, }: ProformaDetailInspectorProps) => { const { t } = useTranslation(); return ( - + + ); }; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx index b8f0e1a4..7d1f2b9b 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx @@ -1,32 +1,108 @@ +import { Button } from "@repo/shadcn-ui/components"; +import { ExternalLinkIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; import type { ProformaDetailGeneralViewModel } from "../../entities"; -import { - ProformaCommercialSection, - ProformaCustomerSection, - ProformaFiscalSection, - ProformaLinkedInvoiceSection, - ProformaSummarySection, -} from "../sections"; +import { ProformaDetailField } from "../components"; export interface ProformaGeneralPanelProps { general: ProformaDetailGeneralViewModel; onOpenCustomer: () => void; + onOpenLinkedInvoice: () => void; } +const EMPTY_VALUE = "\u2014"; + export const ProformaGeneralPanel = ({ general, onOpenCustomer, + onOpenLinkedInvoice, }: ProformaGeneralPanelProps) => { + const { t } = useTranslation(); + return ( -
- - - - - +
+
+

+ {general.summary.totalLabel} +

+

{t("proformas.detail.fields.total")}

+
+ +
+ +
+ + + + + + + + +
+ +
+

{t("proformas.detail.fields.description")}

+

+ {general.content.description ?? EMPTY_VALUE} +

+
+ +
+

{t("form_groups.proformas.customer.title")}

+
+

{general.customer.name}

+ +
+
); }; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx index a96e49c1..03cb5cac 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx @@ -1,4 +1,5 @@ import { PageErrorState } from "@erp/core/components"; + import { ChangeProformaStatusDialog } from "../../../change-status"; import { IssueProformaDialog } from "../../../issue-proforma"; import { useProformaDetailPageController } from "../../controllers"; @@ -38,7 +39,7 @@ export const ProformaDetailPage = () => {
@@ -46,7 +47,7 @@ export const ProformaDetailPage = () => { { } inspector={ } /> diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts index 2539d2b3..714548ea 100644 --- a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-view-models.ts @@ -245,7 +245,7 @@ export const buildProformaDetailGeneralViewModel = ( secondaryText: context.translate( `proformas.detail.linked_invoice.states.${linkedInvoiceState}.description` ), - canOpenLinkedInvoice: false, + canOpenLinkedInvoice: Boolean(proforma.linkedInvoiceId), }; const summary: ProformaDetailSummaryViewModel = {