feat: add issued invoice detail page and related functionality

- Implemented IssuedInvoiceDetailPage component with loading and error states.
- Created utility functions for building issued invoice detail actions and view models.
- Added API adapter for fetching issued invoice details by ID.
- Developed hooks for managing issued invoice queries.
- Introduced new entities and types for issued invoices, including items, taxes, and recipient details.
- Added tests for view models and API adapters to ensure correctness.
- Organized code structure by creating separate files for components, utilities, and hooks.
This commit is contained in:
David Arranz 2026-08-11 17:25:17 +02:00
parent f8bd2f3078
commit a0bd39c2a5
59 changed files with 2549 additions and 255 deletions

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: (
<IssuedInvoicesLayout>
<IssuedInvoiceDetailPage />
</IssuedInvoicesLayout>
),
},
];
};

View File

@ -0,0 +1 @@
export * from "./use-issued-invoice-detail-page-controller";

View File

@ -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: <Se utiliza para reemplazar caracteres inválidos en el nombre del archivo>
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<string | null>(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<string, string | number | null | undefined>) =>
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<string, string | number | null | undefined>) =>
t(key, params),
})
: null;
const document: IssuedInvoiceDetailDocumentViewModel | null = issuedInvoice
? buildIssuedInvoiceDetailDocumentViewModel(previewPdfUrl, {
translate: (key: string, params?: Record<string, string | number | null | undefined>) =>
t(key, params),
})
: null;
const general: IssuedInvoiceDetailGeneralViewModel | null = issuedInvoice
? buildIssuedInvoiceDetailGeneralViewModel(issuedInvoice, {
translate: (key: string, params?: Record<string, string | number | null | undefined>) =>
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,
},
};
};

View File

@ -0,0 +1 @@
export * from "./issued-invoice-detail-view-models.entity";

View File

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

View File

@ -0,0 +1,4 @@
export * from "./controllers";
export * from "./entities";
export * from "./ui";
export * from "./utils";

View File

@ -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";

View File

@ -0,0 +1,20 @@
import type { ReactNode } from "react";
type IssuedInvoiceDetailContentProps = {
documentPane: ReactNode;
inspector: ReactNode;
};
export const IssuedInvoiceDetailContent = ({
documentPane,
inspector,
}: IssuedInvoiceDetailContentProps) => {
return (
<div className="min-h-0 flex-1 overflow-auto lg:overflow-hidden">
<div className="grid min-h-full grid-cols-1 lg:h-full lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_340px] lg:overflow-hidden xl:grid-cols-[minmax(0,1fr)_380px]">
{documentPane}
{inspector}
</div>
</div>
);
};

View File

@ -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 (
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{secondaryActions.map((action) => {
const Icon = getActionIcon(action.key);
return (
<Button
disabled={!action.enabled}
key={action.key}
onClick={() => onExecuteAction(action.key)}
type="button"
variant="outline"
>
{action.loading ? (
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
) : (
<Icon className="mr-2 size-4" />
)}
{action.label}
</Button>
);
})}
{menuActions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
size="icon"
type="button"
variant="outline"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
{menuActions.map((action) => (
<DropdownMenuItem
disabled={!action.enabled}
key={action.key}
onClick={() => onExecuteAction(action.key)}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
};

View File

@ -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 (
<IssuedInvoicePageHeader
actions={
<IssuedInvoiceDetailHeaderActions actions={actions} onExecuteAction={onExecuteAction} />
}
backLabel={t("pages.issued_invoices.detail.actions.back")}
onBack={onBack}
title={header.title}
>
<div className="border-b bg-background px-4 py-4 sm:px-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="font-mono font-medium text-foreground">{header.documentReference}</span>
<span>{header.statusLabel}</span>
<span>{header.customerName}</span>
<span>{header.invoiceDateLabel}</span>
</div>
</div>
</IssuedInvoicePageHeader>
);
};

View File

@ -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 (
<RdxContextPanel
className="overflow-visible bg-background lg:min-h-0 lg:overflow-hidden lg:border-l"
description={t("pages.issued_invoices.detail.description")}
title={t("pages.issued_invoices.detail.regions.details")}
>
<IssuedInvoiceGeneralPanel
general={general}
onOpenLinkedProforma={onOpenLinkedProforma}
/>
</RdxContextPanel>
);
};

View File

@ -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 (
<section
aria-labelledby={headingId}
className="min-w-0 overflow-visible border-b bg-muted/30 lg:min-h-0 lg:overflow-hidden lg:border-b-0"
>
<div
className={cn(
"h-full min-h-0",
shouldUseNativeInlinePdf ? "flex flex-col overflow-hidden p-0" : "p-2 sm:p-4 lg:p-6"
)}
>
<h2 className="sr-only" id={headingId}>
{document.title}
</h2>
{shouldRenderStaleError ? (
<Alert className={cn(shouldUseNativeInlinePdf ? "m-4 mb-0 shrink-0" : "mb-4")}>
<AlertTitle>{t("pages.issued_invoices.detail.document.error_title")}</AlertTitle>
<AlertDescription className="space-y-3">
<p>{state.errorMessage ?? t("pages.issued_invoices.detail.document.error")}</p>
{onRetry ? (
<Button onClick={onRetry} type="button" variant="outline">
{t("pages.issued_invoices.detail.document.retry")}
</Button>
) : null}
</AlertDescription>
</Alert>
) : null}
<PDFViewer
className={cn(
"min-h-[56svh] overflow-hidden border-border/80 bg-background shadow-none sm:min-h-[32rem] lg:h-full lg:min-h-full",
shouldUseNativeInlinePdf && "flex-1 min-h-0"
)}
defaultZoom="fit"
description={document.description}
error={shouldRenderBlockingError ? state.errorMessage : null}
isRefreshing={isRefreshing}
labels={{
download: t("pages.issued_invoices.detail.document.actions.download"),
emptyDescription: t("pages.issued_invoices.detail.document.empty_description"),
emptyTitle: t("pages.issued_invoices.detail.document.empty_title"),
errorTitle: t("pages.issued_invoices.detail.document.error_title"),
fit: t("pages.issued_invoices.detail.document.actions.fit"),
loading: t("pages.issued_invoices.detail.document.loading"),
open: t("pages.issued_invoices.detail.document.actions.open"),
print: t("pages.issued_invoices.detail.document.actions.print"),
refreshing: t("pages.issued_invoices.detail.document.refreshing"),
retry: t("pages.issued_invoices.detail.document.retry"),
zoomIn: t("pages.issued_invoices.detail.document.actions.zoom_in"),
zoomOut: t("pages.issued_invoices.detail.document.actions.zoom_out"),
zoomSelect: t("pages.issued_invoices.detail.document.actions.zoom_select"),
}}
loading={state.isInitialLoading}
onDownload={onDownload}
onOpen={onOpen}
onPrint={onPrint}
onRetry={onRetry}
showDownloadAction={false}
showOpenAction={false}
showPrinterAction={false}
src={document.src}
title={document.title}
variant={shouldUseNativeInlinePdf ? "native-inline" : "panel"}
/>
</div>
</section>
);
};

View File

@ -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 (
<div className="space-y-5 px-4 py-4 sm:px-5">
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{general.summary.totalLabel}
</p>
<p className="text-sm text-muted-foreground">
{t("pages.issued_invoices.detail.fields.total_amount")}
</p>
</div>
<div className="border-t" />
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.invoice_reference")}
value={general.summary.documentReference}
valueClassName="font-mono font-medium text-foreground sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.status")}
value={general.summary.statusLabel}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.invoice_date")}
value={general.summary.invoiceDateLabel}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.operation_date")}
value={general.summary.operationDateLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.internal_reference")}
value={general.commercial.internalReferenceLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_method")}
value={general.commercial.paymentMethodLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.sections.linked_proforma")}
value={
general.linkedProforma.canOpenLinkedProforma ? (
<Button
className="h-auto px-0 py-0 font-medium"
onClick={onOpenLinkedProforma}
type="button"
variant="link"
>
<ExternalLinkIcon aria-hidden="true" className="mr-1 size-4" />
{t("pages.issued_invoices.detail.actions.open_linked_proforma")}
</Button>
) : (
EMPTY_VALUE
)
}
valueClassName="sm:text-right"
/>
</div>
<div className="space-y-2 border-t pt-5">
<p className="text-sm text-muted-foreground">
{t("pages.issued_invoices.detail.fields.description")}
</p>
<p className="text-sm font-medium text-foreground">
{general.content.description || EMPTY_VALUE}
</p>
</div>
<div className="space-y-2 border-t pt-5">
<p className="text-sm text-muted-foreground">
{t("pages.issued_invoices.detail.sections.customer")}
</p>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">{general.customer.name}</p>
</div>
</div>
</div>
);
};

View File

@ -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 (
<PageFormHeader
actions={actions}
backLabel={backLabel}
className={cn("shrink-0", className)}
contentClassName={contentClassName}
onBack={onBack}
showStatus={showStatus}
statusLabel={statusLabel}
title={title}
>
{children}
</PageFormHeader>
);
};

View File

@ -0,0 +1 @@
export * from './issued-invoice-detail-field';

View File

@ -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 (
<div className="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start sm:gap-4">
<span className="text-sm text-muted-foreground">{label}</span>
<div
className={cn(
"text-sm font-medium text-foreground sm:text-right sm:text-balance",
valueClassName
)}
>
{value}
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./blocks";
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./issued-invoice-detail-page";

View File

@ -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 (
<div className="flex h-full min-h-[24rem] items-center justify-center p-6">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-4" />
<span>{t("pages.issued_invoices.detail.document.loading")}</span>
</div>
</div>
);
}
if (ctrl.state.pageError) {
return (
<div className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.state.pageError.description}
primaryAction={ctrl.state.pageError.primaryAction}
reference={ctrl.state.pageError.reference}
secondaryAction={ctrl.state.pageError.secondaryAction}
title={ctrl.state.pageError.title}
variant={ctrl.state.pageError.variant}
/>
</div>
</div>
);
}
if (!(ctrl.header && ctrl.document && ctrl.general)) {
return (
<div className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={t("pages.issued_invoices.detail.page_error.inconsistent.description")}
secondaryAction={{
label: t("pages.issued_invoices.detail.page_error.actions.back_to_list"),
onClick: ctrl.navigation.navigateBack,
}}
title={t("pages.issued_invoices.detail.page_error.unknown.title")}
variant="unknown"
/>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<IssuedInvoiceDetailHeader
actions={ctrl.actions}
header={ctrl.header}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
<IssuedInvoiceDetailContent
documentPane={
<IssuedInvoiceDocumentPane
document={ctrl.document}
onDownload={ctrl.viewer.handleDownload}
onOpen={ctrl.viewer.handleOpenPreviewPdf}
onPrint={ctrl.viewer.handlePrintPreview}
onRetry={ctrl.viewer.handleRetryDocument}
state={ctrl.documentState}
/>
}
inspector={
<IssuedInvoiceDetailInspector
general={ctrl.general}
onOpenLinkedProforma={ctrl.navigation.openLinkedProforma}
/>
}
/>
</div>
);
};

View File

@ -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<Record<IssuedInvoiceDetailActionKey, boolean>>;
}
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),
],
};
};

View File

@ -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<string, string | number | null | undefined>) => {
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);
});

View File

@ -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, string | number | null | undefined>): 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,
};
};

View File

@ -0,0 +1,2 @@
export * from "./build-issued-invoice-detail-actions";
export * from "./build-issued-invoice-detail-view-models";

View File

@ -0,0 +1,3 @@
export * from "./api";
export * from "./controller";
export * from "./hooks";

View File

@ -1,2 +0,0 @@
export * from "./use-issued-invoice-query";
export * from "./use-issued-invoices-query";

View File

@ -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<IssueInvoice, DefaultError>({
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<IssueInvoice>("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.
*/

View File

@ -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<IssuedInvoicesummaryPage, DefaultError>({
queryKey: ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<IssuedInvoicesummaryPage>("issued-invoices", {
signal,
...criteria,
});
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -1 +1,4 @@
export * from "./pages";
export * from "./detail";
export * from "./download-pdf";
export * from "./list";
export * from "./shared";

View File

@ -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";

View File

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

View File

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

View File

@ -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 (
<ButtonGroup>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8"
onClick={(e) => {
stop(e);
actionHandlers.onView?.(issuedInvoice);
}}
size="icon"
variant="ghost"
>
<EyeIcon className="size-4 cursor-pointer" />
<span className="sr-only">
{t("pages.issued_invoices.list.actions.view")}
</span>
</Button>
}
/>
<TooltipContent>{t("pages.issued_invoices.list.actions.view")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
@ -396,45 +412,8 @@ export function useIssuedInvoicesGridColumns(
</Button>
}
/>
<TooltipContent>Descargar PDF</TooltipContent>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => actionHandlers.onDownloadPdf?.(issuedInvoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer" onClick={stop}>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
},

View File

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

View File

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

View File

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

View File

@ -1 +1,2 @@
export * from "./list-issued-invoice.adapter";
export * from "./get-issued-invoice-by-id.adapter";

View File

@ -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<GetIssuedInvoiceByIdResult> {
const { id, signal } = params;
if (!id) {
throw new Error("issuedInvoiceId is required");
}
return dataSource.getOne<GetIssuedInvoiceByIdResponseDTO>("issued-invoices", id, { signal });
}

View File

@ -1 +1,2 @@
export * from "./list-issued-invoices-by-criteria.api";
export * from "./get-issued-invoice-by-id.api";

View File

@ -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";

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,2 @@
export * from "./use-issued-invoice-list-query";
export * from "./use-issued-invoice-get-query";

View File

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

View File

@ -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<IssuedInvoice, DefaultError> => {
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,
});
};

View File

@ -41,6 +41,7 @@ import {
} from "../utils";
const PROFORMAS_FALLBACK_PATH = "/proformas";
// biome-ignore lint/suspicious/noControlCharactersInRegex: <Se utiliza para reemplazar caracteres inválidos en el nombre del archivo>
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,

View File

@ -117,7 +117,7 @@ export interface ProformaDetailLinkedInvoiceViewModel {
linkedInvoiceId: string | null;
primaryText: string;
secondaryText: string | null;
canOpenLinkedInvoice: false;
canOpenLinkedInvoice: boolean;
}
export interface ProformaDetailGeneralViewModel {

View File

@ -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 (
<aside
aria-label={t("proformas.detail.regions.details")}
<RdxContextPanel
className="overflow-visible bg-background lg:min-h-0 lg:overflow-hidden lg:border-l"
description={t("pages.proformas.view.description")}
title={t("proformas.detail.regions.details")}
>
<ScrollArea className="h-auto lg:h-full">
<div className="space-y-4 p-4 sm:p-6">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight text-foreground">
{t("proformas.detail.regions.details")}
</h2>
<p className="text-sm text-muted-foreground">{t("pages.proformas.view.description")}</p>
</div>
<Separator />
<ProformaGeneralPanel general={general} onOpenCustomer={onOpenCustomer} />
</div>
</ScrollArea>
</aside>
<ProformaGeneralPanel
general={general}
onOpenCustomer={onOpenCustomer}
onOpenLinkedInvoice={onOpenLinkedInvoice}
/>
</RdxContextPanel>
);
};

View File

@ -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 (
<div className="space-y-6">
<ProformaSummarySection
content={general.content}
dates={general.dates}
summary={general.summary}
/>
<ProformaCustomerSection customer={general.customer} onOpenCustomer={onOpenCustomer} />
<ProformaCommercialSection commercial={general.commercial} />
<ProformaFiscalSection fiscal={general.fiscal} />
<ProformaLinkedInvoiceSection linkedInvoice={general.linkedInvoice} />
<div className="space-y-5 px-4 py-4 sm:px-5">
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{general.summary.totalLabel}
</p>
<p className="text-sm text-muted-foreground">{t("proformas.detail.fields.total")}</p>
</div>
<div className="border-t" />
<div className="space-y-3">
<ProformaDetailField
label={t("proformas.detail.fields.reference")}
value={general.summary.documentReference}
/>
<ProformaDetailField
label={t("proformas.detail.fields.status")}
value={general.summary.statusLabel}
/>
<ProformaDetailField
label={t("proformas.detail.fields.proforma_date")}
value={general.dates.proformaDateLabel}
/>
<ProformaDetailField
label={t("proformas.detail.fields.operation_date")}
value={general.dates.operationDateLabel ?? EMPTY_VALUE}
/>
<ProformaDetailField
label={t("proformas.detail.fields.internal_reference")}
value={general.commercial.internalReferenceLabel ?? EMPTY_VALUE}
/>
<ProformaDetailField
label={t("proformas.detail.fields.payment_method")}
value={general.commercial.paymentMethodLabel ?? EMPTY_VALUE}
/>
<ProformaDetailField
label={t("proformas.detail.fields.payment_term")}
value={general.commercial.paymentTermLabel ?? EMPTY_VALUE}
/>
<ProformaDetailField
label={t("proformas.detail.sections.linked_invoice")}
value={
general.linkedInvoice.canOpenLinkedInvoice ? (
<Button
className="h-auto px-0 py-0 font-medium"
onClick={onOpenLinkedInvoice}
type="button"
variant="link"
>
<ExternalLinkIcon aria-hidden="true" className="mr-1 size-4" />
{t("pages.proformas.list.actions.view_linked_invoice")}
</Button>
) : (
EMPTY_VALUE
)
}
valueClassName="sm:text-right"
/>
</div>
<div className="space-y-2 border-t pt-5">
<p className="text-sm text-muted-foreground">{t("proformas.detail.fields.description")}</p>
<p className="text-sm font-medium text-foreground">
{general.content.description ?? EMPTY_VALUE}
</p>
</div>
<div className="space-y-2 border-t pt-5">
<p className="text-sm text-muted-foreground">{t("form_groups.proformas.customer.title")}</p>
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">{general.customer.name}</p>
<Button
disabled={!general.customer.canOpenCustomer}
onClick={onOpenCustomer}
type="button"
variant="outline"
>
<ExternalLinkIcon aria-hidden="true" className="mr-2 size-4" />
{t("proformas.detail.actions.open_customer")}
</Button>
</div>
</div>
</div>
);
};

View File

@ -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 = () => {
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header}
header={ctrl.header!}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
@ -46,7 +47,7 @@ export const ProformaDetailPage = () => {
<ProformaDetailContent
documentPane={
<ProformaDocumentPane
document={ctrl.document}
document={ctrl.document!}
onDownload={ctrl.viewer.handleDownload}
onOpen={ctrl.viewer.handleOpenPreviewPdf}
onPrint={ctrl.viewer.handlePrintPreview}
@ -56,8 +57,9 @@ export const ProformaDetailPage = () => {
}
inspector={
<ProformaDetailInspector
general={ctrl.general}
onOpenCustomer={ctrl.navigation.openCustomer}
general={ctrl.general!}
onOpenCustomer={ctrl.navigation.openCustomerInNewTab}
onOpenLinkedInvoice={ctrl.navigation.openLinkedInvoiceInNewTab}
/>
}
/>

View File

@ -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 = {