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:
parent
f8bd2f3078
commit
a0bd39c2a5
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-issued-invoice-detail-page-controller";
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issued-invoice-detail-view-models.entity";
|
||||
@ -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[];
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./controllers";
|
||||
export * from "./entities";
|
||||
export * from "./ui";
|
||||
export * from "./utils";
|
||||
@ -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";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './issued-invoice-detail-field';
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./blocks";
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issued-invoice-detail-page";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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),
|
||||
],
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./build-issued-invoice-detail-actions";
|
||||
export * from "./build-issued-invoice-detail-view-models";
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./controller";
|
||||
export * from "./hooks";
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./use-issued-invoice-query";
|
||||
export * from "./use-issued-invoices-query";
|
||||
@ -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.
|
||||
|
||||
*/
|
||||
@ -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`)
|
||||
});
|
||||
};
|
||||
@ -1 +1,4 @@
|
||||
export * from "./pages";
|
||||
export * from "./detail";
|
||||
export * from "./download-pdf";
|
||||
export * from "./list";
|
||||
export * from "./shared";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./list-issued-invoice.adapter";
|
||||
export * from "./get-issued-invoice-by-id.adapter";
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from "./list-issued-invoices-by-criteria.api";
|
||||
export * from "./get-issued-invoice-by-id.api";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./use-issued-invoice-list-query";
|
||||
export * from "./use-issued-invoice-get-query";
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -117,7 +117,7 @@ export interface ProformaDetailLinkedInvoiceViewModel {
|
||||
linkedInvoiceId: string | null;
|
||||
primaryText: string;
|
||||
secondaryText: string | null;
|
||||
canOpenLinkedInvoice: false;
|
||||
canOpenLinkedInvoice: boolean;
|
||||
}
|
||||
|
||||
export interface ProformaDetailGeneralViewModel {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user