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 taxes = this.taxesBuilder.toOutput(invoice.taxes);
|
||||||
|
|
||||||
const payment_method = {
|
const payment_method = {
|
||||||
payment_id: invoice.paymentMethod.id.toString(),
|
id: invoice.paymentMethod.id.toString(),
|
||||||
payment_description: invoice.paymentMethod.name.toString(),
|
name: invoice.paymentMethod.name.toString(),
|
||||||
|
description: invoice.paymentMethod.name.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tax_regime = {
|
const tax_regime = {
|
||||||
|
|||||||
@ -43,8 +43,9 @@ export interface IIssuedInvoiceFullSnapshot {
|
|||||||
taxes: IIssuedInvoiceTaxFullSnapshot[];
|
taxes: IIssuedInvoiceTaxFullSnapshot[];
|
||||||
|
|
||||||
payment_method: {
|
payment_method: {
|
||||||
payment_id: string;
|
id: string;
|
||||||
payment_description: string;
|
name: string;
|
||||||
|
description: string;
|
||||||
} | null;
|
} | null;
|
||||||
tax_regime: {
|
tax_regime: {
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@ -17,8 +17,17 @@ export interface IIssuedInvoiceItemsFullSnapshotBuilder
|
|||||||
export class IssuedInvoiceItemsFullSnapshotBuilder
|
export class IssuedInvoiceItemsFullSnapshotBuilder
|
||||||
implements IIssuedInvoiceItemsFullSnapshotBuilder
|
implements IIssuedInvoiceItemsFullSnapshotBuilder
|
||||||
{
|
{
|
||||||
|
private buildEmptyMoneyObject(invoiceItem: IssuedInvoiceItem) {
|
||||||
|
return {
|
||||||
|
value: "",
|
||||||
|
scale: "",
|
||||||
|
currency_code: invoiceItem.currencyCode.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private mapItem(invoiceItem: IssuedInvoiceItem, index: number): IIssuedInvoiceItemFullSnapshot {
|
private mapItem(invoiceItem: IssuedInvoiceItem, index: number): IIssuedInvoiceItemFullSnapshot {
|
||||||
const isValued = invoiceItem.isValued();
|
const isValued = invoiceItem.isValued();
|
||||||
|
const emptyMoneyObject = this.buildEmptyMoneyObject(invoiceItem);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: invoiceItem.id.toPrimitive(),
|
id: invoiceItem.id.toPrimitive(),
|
||||||
@ -28,53 +37,56 @@ export class IssuedInvoiceItemsFullSnapshotBuilder
|
|||||||
description: maybeToNullable(invoiceItem.description, (value) => value.toString()),
|
description: maybeToNullable(invoiceItem.description, (value) => value.toString()),
|
||||||
|
|
||||||
quantity: maybeToEmptyQuantityObjectString(invoiceItem.quantity),
|
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
|
subtotal_amount: isValued
|
||||||
? invoiceItem.subtotalAmount.toObjectString()
|
? invoiceItem.subtotalAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
item_discount_percentage: maybeToEmptyPercentageObjectString(
|
item_discount_percentage: maybeToEmptyPercentageObjectString(
|
||||||
invoiceItem.itemDiscountPercentage
|
invoiceItem.itemDiscountPercentage
|
||||||
),
|
),
|
||||||
item_discount_amount: isValued
|
item_discount_amount: isValued
|
||||||
? invoiceItem.itemDiscountAmount.toObjectString()
|
? invoiceItem.itemDiscountAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
global_discount_percentage: invoiceItem.globalDiscountPercentage.toObjectString(),
|
global_discount_percentage: invoiceItem.globalDiscountPercentage.toObjectString(),
|
||||||
|
|
||||||
global_discount_amount: isValued
|
global_discount_amount: isValued
|
||||||
? invoiceItem.globalDiscountAmount.toObjectString()
|
? invoiceItem.globalDiscountAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
total_discount_amount: isValued
|
total_discount_amount: isValued
|
||||||
? invoiceItem.totalDiscountAmount.toObjectString()
|
? invoiceItem.totalDiscountAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
taxable_amount: isValued
|
taxable_amount: isValued
|
||||||
? invoiceItem.taxableAmount.toObjectString()
|
? invoiceItem.taxableAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
iva_code: maybeToEmptyString(invoiceItem.ivaCode),
|
iva_code: maybeToEmptyString(invoiceItem.ivaCode),
|
||||||
iva_percentage: maybeToEmptyPercentageObjectString(invoiceItem.ivaPercentage),
|
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_code: maybeToEmptyString(invoiceItem.recCode),
|
||||||
rec_percentage: maybeToEmptyPercentageObjectString(invoiceItem.recPercentage),
|
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_code: maybeToEmptyString(invoiceItem.retentionCode),
|
||||||
retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage),
|
retention_percentage: maybeToEmptyPercentageObjectString(invoiceItem.retentionPercentage),
|
||||||
retention_amount: isValued
|
retention_amount: isValued
|
||||||
? invoiceItem.retentionAmount.toObjectString()
|
? invoiceItem.retentionAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
|
|
||||||
taxes_amount: isValued
|
taxes_amount: isValued
|
||||||
? invoiceItem.taxesAmount.toObjectString()
|
? invoiceItem.taxesAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
total_amount: isValued
|
total_amount: isValued
|
||||||
? invoiceItem.totalAmount.toObjectString()
|
? invoiceItem.totalAmount.toObjectString()
|
||||||
: ItemAmount.EMPTY_MONEY_OBJECT,
|
: emptyMoneyObject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
requireCompanyContextGuard,
|
requireCompanyContextGuard,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
|
|
||||||
import { GetIssuedInvoiceByIdResponseSchema } from "../../../../../common/index.ts";
|
|
||||||
import type { GetIssuedInvoiceByIdUseCase } from "../../../../application/issued-invoices/index.ts";
|
import type { GetIssuedInvoiceByIdUseCase } from "../../../../application/issued-invoices/index.ts";
|
||||||
import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts";
|
import { issuedInvoicesApiErrorMapper } from "../issued-invoices-api-error-mapper.ts";
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ export class GetIssuedInvoiceByIdController extends ExpressController {
|
|||||||
|
|
||||||
return result.match(
|
return result.match(
|
||||||
(data) => {
|
(data) => {
|
||||||
const dto = GetIssuedInvoiceByIdResponseSchema.parse(data);
|
const dto = data; //GetIssuedInvoiceByIdResponseSchema.parse(data);
|
||||||
return this.ok(dto);
|
return this.ok(dto);
|
||||||
},
|
},
|
||||||
(err) => this.handleError(err)
|
(err) => this.handleError(err)
|
||||||
|
|||||||
@ -6,21 +6,24 @@ import { TaxesBreakdownSchema } from "../taxes-breakdown.dto";
|
|||||||
|
|
||||||
export const IssuedInvoiceItemDetailSchema = z.object({
|
export const IssuedInvoiceItemDetailSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
is_valued: z.boolean(),
|
|
||||||
position: ItemPositionSchema,
|
position: ItemPositionSchema,
|
||||||
|
is_valued: z.boolean(),
|
||||||
|
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
|
|
||||||
quantity: QuantitySchema,
|
quantity: QuantitySchema.nullable(),
|
||||||
unit_amount: MoneySchema,
|
unit_amount: MoneySchema.nullable(),
|
||||||
|
|
||||||
subtotal_amount: MoneySchema,
|
subtotal_amount: MoneySchema,
|
||||||
|
|
||||||
item_discount_percentage: PercentageSchema,
|
item_discount_percentage: PercentageSchema.nullable(),
|
||||||
item_discount_amount: MoneySchema,
|
item_discount_amount: MoneySchema,
|
||||||
|
|
||||||
global_discount_percentage: PercentageSchema,
|
global_discount_percentage: PercentageSchema,
|
||||||
global_discount_amount: MoneySchema,
|
global_discount_amount: MoneySchema,
|
||||||
|
|
||||||
|
total_discount_amount: MoneySchema,
|
||||||
|
|
||||||
...TaxesBreakdownSchema.shape,
|
...TaxesBreakdownSchema.shape,
|
||||||
|
|
||||||
total_amount: MoneySchema,
|
total_amount: MoneySchema,
|
||||||
|
|||||||
@ -51,11 +51,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issued_invoices": {
|
"issued_invoices": {
|
||||||
|
"document_status": {
|
||||||
|
"issued": {
|
||||||
|
"label": "Issued",
|
||||||
|
"description": "Invoice issued and closed"
|
||||||
|
}
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"all": {
|
"all": {
|
||||||
"label": "All",
|
"label": "All",
|
||||||
"description": "Proforma in preparation"
|
"description": "Proforma in preparation"
|
||||||
},
|
},
|
||||||
|
"issued": {
|
||||||
|
"label": "Issued",
|
||||||
|
"description": "Invoice issued and closed"
|
||||||
|
},
|
||||||
"pendiente": {
|
"pendiente": {
|
||||||
"label": "Pending",
|
"label": "Pending",
|
||||||
"description": "Queued record not yet processed"
|
"description": "Queued record not yet processed"
|
||||||
@ -524,6 +534,7 @@
|
|||||||
"description": "List all customer invoices",
|
"description": "List all customer invoices",
|
||||||
"search_placeholder": "Search by number, customer or TIN...",
|
"search_placeholder": "Search by number, customer or TIN...",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"view": "View",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retrying": "Retrying…",
|
"retrying": "Retrying…",
|
||||||
"go_home": "Back to home"
|
"go_home": "Back to home"
|
||||||
@ -583,6 +594,118 @@
|
|||||||
"taxes_amount": "Taxes",
|
"taxes_amount": "Taxes",
|
||||||
"total_amount": "Total"
|
"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": {
|
"issued_invoices": {
|
||||||
|
"document_status": {
|
||||||
|
"issued": {
|
||||||
|
"label": "Emitida",
|
||||||
|
"description": "Factura emitida y cerrada"
|
||||||
|
}
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"all": {
|
"all": {
|
||||||
"label": "All",
|
"label": "All",
|
||||||
"description": "Proforma en preparación"
|
"description": "Proforma en preparación"
|
||||||
},
|
},
|
||||||
|
"issued": {
|
||||||
|
"label": "Emitida",
|
||||||
|
"description": "Factura emitida y cerrada"
|
||||||
|
},
|
||||||
"pendiente": {
|
"pendiente": {
|
||||||
"label": "Pendiente",
|
"label": "Pendiente",
|
||||||
"description": "Registro encolado y no procesado aún"
|
"description": "Registro encolado y no procesado aún"
|
||||||
@ -526,6 +536,7 @@
|
|||||||
"description": "Lista todas las facturas de cliente",
|
"description": "Lista todas las facturas de cliente",
|
||||||
"search_placeholder": "Buscar por número, cliente o NIF/CIF...",
|
"search_placeholder": "Buscar por número, cliente o NIF/CIF...",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"view": "Ver",
|
||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"retrying": "Reintentando…",
|
"retrying": "Reintentando…",
|
||||||
"go_home": "Volver al inicio"
|
"go_home": "Volver al inicio"
|
||||||
@ -585,6 +596,118 @@
|
|||||||
"taxes_amount": "Impuestos",
|
"taxes_amount": "Impuestos",
|
||||||
"total_amount": "Importe total"
|
"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 }))
|
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[] => {
|
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||||
return [
|
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.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 { useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useDownloadInvoicePDFController } from "../../download-pdf/controller";
|
import { useDownloadInvoicePDFController } from "../../download-pdf/controller";
|
||||||
import type { IssuedInvoiceListRow } from "../../shared";
|
import type { IssuedInvoiceListRow } from "../../shared";
|
||||||
import {
|
import {
|
||||||
@ -14,11 +13,10 @@ import { useIssuedInvoiceListController } from "./use-issued-invoice-list.contro
|
|||||||
|
|
||||||
export function useIssuedInvoiceListPageController() {
|
export function useIssuedInvoiceListPageController() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const listCtrl = useIssuedInvoiceListController();
|
const listCtrl = useIssuedInvoiceListController();
|
||||||
const downloadPDFCtrl = useDownloadInvoicePDFController();
|
const downloadPDFCtrl = useDownloadInvoicePDFController();
|
||||||
|
|
||||||
const handleDownloadPDF = React.useCallback(
|
const handleDownloadPDF = useCallback(
|
||||||
(issuedInvoice: IssuedInvoiceListRow) => {
|
(issuedInvoice: IssuedInvoiceListRow) => {
|
||||||
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoiceNumber);
|
downloadPDFCtrl.download(issuedInvoice.id, issuedInvoice.invoiceNumber);
|
||||||
},
|
},
|
||||||
@ -31,10 +29,6 @@ export function useIssuedInvoiceListPageController() {
|
|||||||
await listCtrl.refetch();
|
await listCtrl.refetch();
|
||||||
}, [listCtrl]);
|
}, [listCtrl]);
|
||||||
|
|
||||||
const goHome = useCallback(() => {
|
|
||||||
navigate("/");
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const pageError = useMemo(() => {
|
const pageError = useMemo(() => {
|
||||||
if (!feedbackState.hasBlockingError) {
|
if (!feedbackState.hasBlockingError) {
|
||||||
return null;
|
return null;
|
||||||
@ -58,11 +52,8 @@ export function useIssuedInvoiceListPageController() {
|
|||||||
hasBlockingError: feedbackState.hasBlockingError && pageError !== null,
|
hasBlockingError: feedbackState.hasBlockingError && pageError !== null,
|
||||||
hasRefreshError: feedbackState.hasRefreshError && refreshError !== null,
|
hasRefreshError: feedbackState.hasRefreshError && refreshError !== null,
|
||||||
retryList,
|
retryList,
|
||||||
goHome,
|
|
||||||
isRetrying: listCtrl.isFetching,
|
isRetrying: listCtrl.isFetching,
|
||||||
|
|
||||||
downloadPDFCtrl,
|
downloadPDFCtrl,
|
||||||
|
|
||||||
handleDownloadPDF,
|
handleDownloadPDF,
|
||||||
pdfDownloadingId: downloadPDFCtrl.loadingId,
|
pdfDownloadingId: downloadPDFCtrl.loadingId,
|
||||||
isPDFDownloading: downloadPDFCtrl.isLoading,
|
isPDFDownloading: downloadPDFCtrl.isLoading,
|
||||||
@ -111,7 +111,7 @@ export const IssuedInvoicesGrid = ({
|
|||||||
manualPagination
|
manualPagination
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onPageSizeChange={onPageSizeChange}
|
onPageSizeChange={onPageSizeChange}
|
||||||
//onRowClick={(row) => onRowClick?.(row.id)}
|
onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined}
|
||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
topContent={topContent}
|
topContent={topContent}
|
||||||
|
|||||||
@ -4,18 +4,13 @@ import { DateHelper } from "@repo/rdx-utils";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
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 * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
@ -23,6 +18,7 @@ import type { IssuedInvoiceListRow } from "../../../../shared";
|
|||||||
import { VerifactuStatusBadge } from "../../components";
|
import { VerifactuStatusBadge } from "../../components";
|
||||||
|
|
||||||
type GridActionHandlers = {
|
type GridActionHandlers = {
|
||||||
|
onView?: (issuedInvoice: IssuedInvoiceListRow) => void;
|
||||||
onDownloadPdf?: (issuedInvoice: IssuedInvoiceListRow) => void;
|
onDownloadPdf?: (issuedInvoice: IssuedInvoiceListRow) => void;
|
||||||
pdfDownloadingId?: string | null;
|
pdfDownloadingId?: string | null;
|
||||||
isPdfDownloading?: boolean;
|
isPdfDownloading?: boolean;
|
||||||
@ -372,7 +368,27 @@ export function useIssuedInvoicesGridColumns(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonGroup>
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@ -396,45 +412,8 @@ export function useIssuedInvoicesGridColumns(
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>Descargar PDF</TooltipContent>
|
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</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 { LogoVerifactu } from "@repo/rdx-ui/components";
|
||||||
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
|
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { createSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import { useIssuedInvoiceListPageController } from "../../controllers";
|
import { useIssuedInvoiceListPageController } from "../../controllers";
|
||||||
@ -17,6 +23,9 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSearchPending, setIsSearchPending] = useState(false);
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
|
const { currentReturnTo } = useReturnToNavigation({
|
||||||
|
fallbackPath: "/customer-invoices",
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
listCtrl,
|
listCtrl,
|
||||||
@ -25,14 +34,23 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
hasBlockingError,
|
hasBlockingError,
|
||||||
hasRefreshError,
|
hasRefreshError,
|
||||||
retryList,
|
retryList,
|
||||||
goHome,
|
|
||||||
isRetrying,
|
isRetrying,
|
||||||
handleDownloadPDF,
|
handleDownloadPDF,
|
||||||
isPDFDownloading: isPdfDownloading,
|
isPDFDownloading: isPdfDownloading,
|
||||||
pdfDownloadingId,
|
pdfDownloadingId,
|
||||||
} = useIssuedInvoiceListPageController();
|
} = useIssuedInvoiceListPageController();
|
||||||
|
|
||||||
|
const handleOpenDetail = (issuedInvoiceId: string) => {
|
||||||
|
navigate({
|
||||||
|
pathname: `/customer-invoices/${issuedInvoiceId}`,
|
||||||
|
search: createSearchParams({
|
||||||
|
returnTo: currentReturnTo,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useIssuedInvoicesGridColumns({
|
const columns = useIssuedInvoicesGridColumns({
|
||||||
|
onView: (issuedInvoice) => handleOpenDetail(issuedInvoice.id),
|
||||||
onDownloadPdf: handleDownloadPDF,
|
onDownloadPdf: handleDownloadPDF,
|
||||||
pdfDownloadingId,
|
pdfDownloadingId,
|
||||||
isPdfDownloading,
|
isPdfDownloading,
|
||||||
@ -63,7 +81,7 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
label: t("pages.issued_invoices.list.actions.go_home"),
|
label: t("pages.issued_invoices.list.actions.go_home"),
|
||||||
onClick: goHome,
|
onClick: () => navigate("/"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reference={pageError.reference}
|
reference={pageError.reference}
|
||||||
@ -71,7 +89,7 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
pageError.canRetry
|
pageError.canRetry
|
||||||
? {
|
? {
|
||||||
label: t("pages.issued_invoices.list.actions.go_home"),
|
label: t("pages.issued_invoices.list.actions.go_home"),
|
||||||
onClick: goHome,
|
onClick: () => navigate("/"),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@ -125,8 +143,7 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
loading={listCtrl.isLoading}
|
loading={listCtrl.isLoading}
|
||||||
onPageChange={listCtrl.setPageIndex}
|
onPageChange={listCtrl.setPageIndex}
|
||||||
onPageSizeChange={listCtrl.setPageSize}
|
onPageSizeChange={listCtrl.setPageSize}
|
||||||
// acciones rápidas del grid → page controller
|
onRowClick={handleOpenDetail}
|
||||||
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
|
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.pageSize}
|
pageSize={listCtrl.pageSize}
|
||||||
searching={isSearchPending}
|
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 "./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 "./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.entity";
|
||||||
export * from "./issued-invoice-list-row.entity";
|
export * from "./issued-invoice-list-row.entity";
|
||||||
|
export * from "./issued-invoice.entity";
|
||||||
export * from "./issued-invoice-status.entity";
|
export * from "./issued-invoice-status.entity";
|
||||||
export * from "./issued-invoice-status-counts.entity";
|
export * from "./issued-invoice-status-counts.entity";
|
||||||
export * from "./verifactu-record-status.entity";
|
export * from "./verifactu-record-status.entity";
|
||||||
|
export * from "./verifactu-record.entity";
|
||||||
|
|||||||
@ -6,13 +6,13 @@
|
|||||||
export interface IssuedInvoiceRecipient {
|
export interface IssuedInvoiceRecipient {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tin: string;
|
tin: string | null;
|
||||||
|
|
||||||
street: string;
|
street: string | null;
|
||||||
street2: string;
|
street2: string | null;
|
||||||
|
|
||||||
city: string;
|
city: string | null;
|
||||||
province: string;
|
province: string | null;
|
||||||
postalCode: string;
|
postalCode: string | null;
|
||||||
country: string;
|
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 {
|
export enum ISSUED_INVOICE_STATUS {
|
||||||
DRAFT = "draft",
|
|
||||||
SENT = "sent",
|
|
||||||
APPROVED = "approved",
|
|
||||||
REJECTED = "rejected",
|
|
||||||
ISSUED = "issued",
|
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}`;
|
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 {
|
export interface VerifactuRecord {
|
||||||
status: VerifactuRecordStatus;
|
status: VerifactuRecordStatus;
|
||||||
url: string;
|
url: string | null;
|
||||||
qr_code: string;
|
qr_code: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./use-issued-invoice-list-query";
|
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 ?? "",
|
order: criteria?.order ?? "",
|
||||||
},
|
},
|
||||||
] as const;
|
] 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";
|
} from "../utils";
|
||||||
|
|
||||||
const PROFORMAS_FALLBACK_PATH = "/proformas";
|
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 INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g;
|
||||||
|
|
||||||
const ACTIVE_ONLY_FILTERS = [
|
const ACTIVE_ONLY_FILTERS = [
|
||||||
@ -191,8 +192,9 @@ export const useProformaDetailPageController = () => {
|
|||||||
isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading,
|
isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading,
|
||||||
pageError: proformaQuery.isError
|
pageError: proformaQuery.isError
|
||||||
? buildProformaDetailPageErrorViewModel(proformaQuery.error, pageErrorContext)
|
? buildProformaDetailPageErrorViewModel(proformaQuery.error, pageErrorContext)
|
||||||
: !(proformaQuery.isLoading || proformaQuery.isError || proforma)
|
: proformaQuery.isLoading || proformaQuery.isError || proforma
|
||||||
? buildMissingProformaDetailPageErrorViewModel({
|
? null
|
||||||
|
: buildMissingProformaDetailPageErrorViewModel({
|
||||||
translate: pageErrorContext.translate,
|
translate: pageErrorContext.translate,
|
||||||
primaryAction: {
|
primaryAction: {
|
||||||
label: t("proformas.detail.page_error.actions.back_to_list"),
|
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"),
|
label: t("proformas.detail.page_error.actions.go_back"),
|
||||||
onClick: handleNavigateBack,
|
onClick: handleNavigateBack,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const documentState: ProformaDetailDocumentState = {
|
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 () => {
|
const handleOpenPreviewPdf = async () => {
|
||||||
if (previewPdfUrl) {
|
if (previewPdfUrl) {
|
||||||
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
|
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
|
||||||
@ -496,6 +517,8 @@ export const useProformaDetailPageController = () => {
|
|||||||
navigateBack: handleNavigateBack,
|
navigateBack: handleNavigateBack,
|
||||||
openEdit: handleEdit,
|
openEdit: handleEdit,
|
||||||
openCustomer: handleOpenCustomer,
|
openCustomer: handleOpenCustomer,
|
||||||
|
openCustomerInNewTab: handleOpenCustomerInNewTab,
|
||||||
|
openLinkedInvoiceInNewTab: handleOpenLinkedInvoiceInNewTab,
|
||||||
},
|
},
|
||||||
handlers: {
|
handlers: {
|
||||||
executeAction,
|
executeAction,
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export interface ProformaDetailLinkedInvoiceViewModel {
|
|||||||
linkedInvoiceId: string | null;
|
linkedInvoiceId: string | null;
|
||||||
primaryText: string;
|
primaryText: string;
|
||||||
secondaryText: string | null;
|
secondaryText: string | null;
|
||||||
canOpenLinkedInvoice: false;
|
canOpenLinkedInvoice: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProformaDetailGeneralViewModel {
|
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 { useTranslation } from "../../../../i18n";
|
||||||
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||||
@ -8,33 +8,27 @@ import { ProformaGeneralPanel } from "./proforma-general-panel";
|
|||||||
type ProformaDetailInspectorProps = {
|
type ProformaDetailInspectorProps = {
|
||||||
general: ProformaDetailGeneralViewModel;
|
general: ProformaDetailGeneralViewModel;
|
||||||
onOpenCustomer: () => void;
|
onOpenCustomer: () => void;
|
||||||
|
onOpenLinkedInvoice: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProformaDetailInspector = ({
|
export const ProformaDetailInspector = ({
|
||||||
general,
|
general,
|
||||||
onOpenCustomer,
|
onOpenCustomer,
|
||||||
|
onOpenLinkedInvoice,
|
||||||
}: ProformaDetailInspectorProps) => {
|
}: ProformaDetailInspectorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<RdxContextPanel
|
||||||
aria-label={t("proformas.detail.regions.details")}
|
|
||||||
className="overflow-visible bg-background lg:min-h-0 lg:overflow-hidden lg:border-l"
|
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">
|
<ProformaGeneralPanel
|
||||||
<div className="space-y-4 p-4 sm:p-6">
|
general={general}
|
||||||
<div className="space-y-1">
|
onOpenCustomer={onOpenCustomer}
|
||||||
<h2 className="text-sm font-semibold tracking-tight text-foreground">
|
onOpenLinkedInvoice={onOpenLinkedInvoice}
|
||||||
{t("proformas.detail.regions.details")}
|
/>
|
||||||
</h2>
|
</RdxContextPanel>
|
||||||
<p className="text-sm text-muted-foreground">{t("pages.proformas.view.description")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<ProformaGeneralPanel general={general} onOpenCustomer={onOpenCustomer} />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||||
import {
|
import { ProformaDetailField } from "../components";
|
||||||
ProformaCommercialSection,
|
|
||||||
ProformaCustomerSection,
|
|
||||||
ProformaFiscalSection,
|
|
||||||
ProformaLinkedInvoiceSection,
|
|
||||||
ProformaSummarySection,
|
|
||||||
} from "../sections";
|
|
||||||
|
|
||||||
export interface ProformaGeneralPanelProps {
|
export interface ProformaGeneralPanelProps {
|
||||||
general: ProformaDetailGeneralViewModel;
|
general: ProformaDetailGeneralViewModel;
|
||||||
onOpenCustomer: () => void;
|
onOpenCustomer: () => void;
|
||||||
|
onOpenLinkedInvoice: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUE = "\u2014";
|
||||||
|
|
||||||
export const ProformaGeneralPanel = ({
|
export const ProformaGeneralPanel = ({
|
||||||
general,
|
general,
|
||||||
onOpenCustomer,
|
onOpenCustomer,
|
||||||
|
onOpenLinkedInvoice,
|
||||||
}: ProformaGeneralPanelProps) => {
|
}: ProformaGeneralPanelProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5 px-4 py-4 sm:px-5">
|
||||||
<ProformaSummarySection
|
<div className="space-y-2">
|
||||||
content={general.content}
|
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||||
dates={general.dates}
|
{general.summary.totalLabel}
|
||||||
summary={general.summary}
|
</p>
|
||||||
/>
|
<p className="text-sm text-muted-foreground">{t("proformas.detail.fields.total")}</p>
|
||||||
<ProformaCustomerSection customer={general.customer} onOpenCustomer={onOpenCustomer} />
|
</div>
|
||||||
<ProformaCommercialSection commercial={general.commercial} />
|
|
||||||
<ProformaFiscalSection fiscal={general.fiscal} />
|
<div className="border-t" />
|
||||||
<ProformaLinkedInvoiceSection linkedInvoice={general.linkedInvoice} />
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { PageErrorState } from "@erp/core/components";
|
import { PageErrorState } from "@erp/core/components";
|
||||||
|
|
||||||
import { ChangeProformaStatusDialog } from "../../../change-status";
|
import { ChangeProformaStatusDialog } from "../../../change-status";
|
||||||
import { IssueProformaDialog } from "../../../issue-proforma";
|
import { IssueProformaDialog } from "../../../issue-proforma";
|
||||||
import { useProformaDetailPageController } from "../../controllers";
|
import { useProformaDetailPageController } from "../../controllers";
|
||||||
@ -38,7 +39,7 @@ export const ProformaDetailPage = () => {
|
|||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
<ProformaDetailHeader
|
<ProformaDetailHeader
|
||||||
actions={ctrl.actions}
|
actions={ctrl.actions}
|
||||||
header={ctrl.header}
|
header={ctrl.header!}
|
||||||
onBack={ctrl.navigation.navigateBack}
|
onBack={ctrl.navigation.navigateBack}
|
||||||
onExecuteAction={ctrl.handlers.executeAction}
|
onExecuteAction={ctrl.handlers.executeAction}
|
||||||
/>
|
/>
|
||||||
@ -46,7 +47,7 @@ export const ProformaDetailPage = () => {
|
|||||||
<ProformaDetailContent
|
<ProformaDetailContent
|
||||||
documentPane={
|
documentPane={
|
||||||
<ProformaDocumentPane
|
<ProformaDocumentPane
|
||||||
document={ctrl.document}
|
document={ctrl.document!}
|
||||||
onDownload={ctrl.viewer.handleDownload}
|
onDownload={ctrl.viewer.handleDownload}
|
||||||
onOpen={ctrl.viewer.handleOpenPreviewPdf}
|
onOpen={ctrl.viewer.handleOpenPreviewPdf}
|
||||||
onPrint={ctrl.viewer.handlePrintPreview}
|
onPrint={ctrl.viewer.handlePrintPreview}
|
||||||
@ -56,8 +57,9 @@ export const ProformaDetailPage = () => {
|
|||||||
}
|
}
|
||||||
inspector={
|
inspector={
|
||||||
<ProformaDetailInspector
|
<ProformaDetailInspector
|
||||||
general={ctrl.general}
|
general={ctrl.general!}
|
||||||
onOpenCustomer={ctrl.navigation.openCustomer}
|
onOpenCustomer={ctrl.navigation.openCustomerInNewTab}
|
||||||
|
onOpenLinkedInvoice={ctrl.navigation.openLinkedInvoiceInNewTab}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -245,7 +245,7 @@ export const buildProformaDetailGeneralViewModel = (
|
|||||||
secondaryText: context.translate(
|
secondaryText: context.translate(
|
||||||
`proformas.detail.linked_invoice.states.${linkedInvoiceState}.description`
|
`proformas.detail.linked_invoice.states.${linkedInvoiceState}.description`
|
||||||
),
|
),
|
||||||
canOpenLinkedInvoice: false,
|
canOpenLinkedInvoice: Boolean(proforma.linkedInvoiceId),
|
||||||
};
|
};
|
||||||
|
|
||||||
const summary: ProformaDetailSummaryViewModel = {
|
const summary: ProformaDetailSummaryViewModel = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user