feat: add proforma detail components and utilities
- Introduced ProformaDetailContent, ProformaDetailHeader, ProformaDetailInspector, ProformaDocumentPane, ProformaGeneralPanel components for displaying detailed proforma information. - Created various sections including ProformaCommercialSection, ProformaContentSection, ProformaCustomerSection, ProformaDatesSection, ProformaFiscalSection, ProformaLinkedInvoiceSection, and ProformaSummarySection to organize proforma details. - Implemented utility functions to build proforma detail actions and view models, enhancing the management of proforma states and actions. - Added translations and improved accessibility features across components.
This commit is contained in:
parent
32598c174c
commit
9cade597d7
@ -183,6 +183,126 @@
|
|||||||
"error_title": "The proforma could not be duplicated",
|
"error_title": "The proforma could not be duplicated",
|
||||||
"error_not_found_message": "The proforma no longer exists or is not available.",
|
"error_not_found_message": "The proforma no longer exists or is not available.",
|
||||||
"error_unknown_message": "An unexpected error occurred while duplicating the proforma."
|
"error_unknown_message": "An unexpected error occurred while duplicating the proforma."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"header": {
|
||||||
|
"title": "Proforma {{reference}}",
|
||||||
|
"archived_label": "Archived"
|
||||||
|
},
|
||||||
|
"document": {
|
||||||
|
"title": "Document",
|
||||||
|
"description": "Preview of the generated PDF for this proforma.",
|
||||||
|
"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 proforma 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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"document": "Document",
|
||||||
|
"details": "Details"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"unknown": "Unnamed customer"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"summary": "Summary",
|
||||||
|
"customer": "Customer",
|
||||||
|
"dates": "Dates",
|
||||||
|
"commercial": "Commercial terms",
|
||||||
|
"fiscal": "Tax",
|
||||||
|
"content": "Description and notes",
|
||||||
|
"linked_invoice": "Related document"
|
||||||
|
},
|
||||||
|
"section_descriptions": {
|
||||||
|
"summary": "Primary operational information for the proforma.",
|
||||||
|
"customer": "",
|
||||||
|
"dates": "Dates available in the current detail contract.",
|
||||||
|
"commercial": "Payment method, payment term and commercial references.",
|
||||||
|
"fiscal": "Tax summary and breakdown prepared from the ViewModel.",
|
||||||
|
"content": "Textual content associated with the proforma.",
|
||||||
|
"linked_invoice": "Current state of the Proforma -> Invoice relationship."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"total": "Total",
|
||||||
|
"reference": "Proforma number",
|
||||||
|
"status": "Status",
|
||||||
|
"currency": "Currency",
|
||||||
|
"lines": "Lines",
|
||||||
|
"units": "Units",
|
||||||
|
"global_discount": "Global discount",
|
||||||
|
"customer_tin": "TIN",
|
||||||
|
"address": "Address",
|
||||||
|
"proforma_date": "Proforma date",
|
||||||
|
"operation_date": "Operation date",
|
||||||
|
"payment_method": "Payment method",
|
||||||
|
"payment_term": "Payment term",
|
||||||
|
"language": "Language",
|
||||||
|
"internal_reference": "Internal reference",
|
||||||
|
"tax_regime": "Tax regime",
|
||||||
|
"tax_mode": "Tax mode",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"discount": "Discount",
|
||||||
|
"taxable_base": "Taxable base",
|
||||||
|
"tax": "Tax",
|
||||||
|
"rec": "Equivalence surcharge",
|
||||||
|
"retention": "Withholding",
|
||||||
|
"tax_breakdown": "Tax breakdown",
|
||||||
|
"description": "Description",
|
||||||
|
"notes": "Notes",
|
||||||
|
"linked_invoice_id": "Linked invoice identifier"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"no_address": "No address recorded.",
|
||||||
|
"not_available": "—",
|
||||||
|
"no_description": "No description.",
|
||||||
|
"no_notes": "No notes."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open_customer": "View customer",
|
||||||
|
"disabled_reasons": {
|
||||||
|
"edit_archived": "Archived proformas must be unarchived before they can be edited.",
|
||||||
|
"status_archived": "Unarchive the proforma before changing its status."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fiscal": {
|
||||||
|
"unknown_tax_code": "Tax",
|
||||||
|
"tax_mode": {
|
||||||
|
"single": "Single tax",
|
||||||
|
"perLine": "Tax per line"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linked_invoice": {
|
||||||
|
"states": {
|
||||||
|
"not-issued": {
|
||||||
|
"title": "Pending issuance",
|
||||||
|
"description": "The proforma has not been converted into an invoice yet."
|
||||||
|
},
|
||||||
|
"ready-to-issue": {
|
||||||
|
"title": "Ready to issue",
|
||||||
|
"description": "It can be converted into an invoice from the header."
|
||||||
|
},
|
||||||
|
"issued-without-link": {
|
||||||
|
"title": "Issued",
|
||||||
|
"description": "No reference to the linked invoice was returned."
|
||||||
|
},
|
||||||
|
"issued-linked-without-detail-route": {
|
||||||
|
"title": "Issued invoice",
|
||||||
|
"description": "The linked invoice exists, but its individual detail view is not available in this application yet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@ -273,8 +393,7 @@
|
|||||||
"description": "Delete the selected customer proforma"
|
"description": "Delete the selected customer proforma"
|
||||||
},
|
},
|
||||||
"delete.error_title": "Unable to delete the proforma",
|
"delete.error_title": "Unable to delete the proforma",
|
||||||
"delete.error_unexpected_message": "An unexpected error occurred while deleting the proforma."
|
"delete.error_unexpected_message": "An unexpected error occurred while deleting the proforma.",
|
||||||
,
|
|
||||||
"view": {
|
"view": {
|
||||||
"title": "View customer proforma",
|
"title": "View customer proforma",
|
||||||
"description": "View the details of the selected customer proforma"
|
"description": "View the details of the selected customer proforma"
|
||||||
@ -471,4 +590,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,6 +184,126 @@
|
|||||||
"error_title": "No se pudo duplicar la proforma",
|
"error_title": "No se pudo duplicar la proforma",
|
||||||
"error_not_found_message": "La proforma ya no existe o no está disponible.",
|
"error_not_found_message": "La proforma ya no existe o no está disponible.",
|
||||||
"error_unknown_message": "Ha ocurrido un error inesperado al duplicar la proforma."
|
"error_unknown_message": "Ha ocurrido un error inesperado al duplicar la proforma."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"header": {
|
||||||
|
"title": "Proforma {{reference}}",
|
||||||
|
"archived_label": "Archivada"
|
||||||
|
},
|
||||||
|
"document": {
|
||||||
|
"title": "Documento",
|
||||||
|
"description": "Vista previa del PDF generado de la proforma.",
|
||||||
|
"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 proforma 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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"document": "Documento",
|
||||||
|
"details": "Detalles"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"unknown": "Cliente sin nombre"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"summary": "Resumen",
|
||||||
|
"customer": "Cliente",
|
||||||
|
"dates": "Fechas",
|
||||||
|
"commercial": "Condiciones comerciales",
|
||||||
|
"fiscal": "Fiscalidad",
|
||||||
|
"content": "Descripción y notas",
|
||||||
|
"linked_invoice": "Documento relacionado"
|
||||||
|
},
|
||||||
|
"section_descriptions": {
|
||||||
|
"summary": "Datos operativos principales de la proforma.",
|
||||||
|
"customer": "",
|
||||||
|
"dates": "Fechas disponibles en el contrato actual del detalle.",
|
||||||
|
"commercial": "Método, plazo y referencias comerciales configuradas.",
|
||||||
|
"fiscal": "Resumen fiscal y desglose preparado desde el ViewModel.",
|
||||||
|
"content": "Contenido textual asociado a la proforma.",
|
||||||
|
"linked_invoice": "Estado actual de la relación Proforma -> Factura."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"total": "Total",
|
||||||
|
"reference": "Número de proforma",
|
||||||
|
"status": "Estado",
|
||||||
|
"currency": "Moneda",
|
||||||
|
"lines": "Líneas",
|
||||||
|
"units": "Unidades",
|
||||||
|
"global_discount": "Descuento global",
|
||||||
|
"customer_tin": "NIF",
|
||||||
|
"address": "Dirección",
|
||||||
|
"proforma_date": "Fecha de proforma",
|
||||||
|
"operation_date": "Fecha de operación",
|
||||||
|
"payment_method": "Método de pago",
|
||||||
|
"payment_term": "Plazo de pago",
|
||||||
|
"language": "Idioma",
|
||||||
|
"internal_reference": "Referencia interna",
|
||||||
|
"tax_regime": "Régimen fiscal",
|
||||||
|
"tax_mode": "Modo fiscal",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"discount": "Descuento",
|
||||||
|
"taxable_base": "Base imponible",
|
||||||
|
"tax": "Impuesto",
|
||||||
|
"rec": "Recargo de equivalencia",
|
||||||
|
"retention": "Retención",
|
||||||
|
"tax_breakdown": "Desglose fiscal",
|
||||||
|
"description": "Descripción",
|
||||||
|
"notes": "Notas",
|
||||||
|
"linked_invoice_id": "Identificador de factura vinculada"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"no_address": "Sin dirección registrada.",
|
||||||
|
"not_available": "—",
|
||||||
|
"no_description": "Sin descripción.",
|
||||||
|
"no_notes": "Sin notas."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open_customer": "Ver cliente",
|
||||||
|
"disabled_reasons": {
|
||||||
|
"edit_archived": "Las proformas archivadas deben desarchivarse antes de editarse.",
|
||||||
|
"status_archived": "Desarchiva la proforma para poder cambiar su estado."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fiscal": {
|
||||||
|
"unknown_tax_code": "Impuesto",
|
||||||
|
"tax_mode": {
|
||||||
|
"single": "Impuesto único",
|
||||||
|
"perLine": "Impuestos por línea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linked_invoice": {
|
||||||
|
"states": {
|
||||||
|
"not-issued": {
|
||||||
|
"title": "Pendiente de emitir",
|
||||||
|
"description": "La proforma todavía no se ha convertido en factura."
|
||||||
|
},
|
||||||
|
"ready-to-issue": {
|
||||||
|
"title": "Lista para emitir",
|
||||||
|
"description": "Puede convertirse en factura desde la cabecera."
|
||||||
|
},
|
||||||
|
"issued-without-link": {
|
||||||
|
"title": "Emitida",
|
||||||
|
"description": "No se ha recibido una referencia a la factura vinculada."
|
||||||
|
},
|
||||||
|
"issued-linked-without-detail-route": {
|
||||||
|
"title": "Factura emitida",
|
||||||
|
"description": "La factura vinculada existe, pero su detalle individual todavía no está disponible en esta aplicación."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@ -274,8 +394,7 @@
|
|||||||
"description": "Eliminar la proforma seleccionada"
|
"description": "Eliminar la proforma seleccionada"
|
||||||
},
|
},
|
||||||
"delete.error_title": "No se pudo eliminar la proforma",
|
"delete.error_title": "No se pudo eliminar la proforma",
|
||||||
"delete.error_unexpected_message": "Ha ocurrido un error inesperado al eliminar la proforma."
|
"delete.error_unexpected_message": "Ha ocurrido un error inesperado al eliminar la proforma.",
|
||||||
,
|
|
||||||
"view": {
|
"view": {
|
||||||
"title": "Ver proforma",
|
"title": "Ver proforma",
|
||||||
"description": "Ver los detalles de la proforma seleccionada"
|
"description": "Ver los detalles de la proforma seleccionada"
|
||||||
@ -463,4 +582,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,250 +1,441 @@
|
|||||||
import { useReturnToNavigation } from "@erp/core/hooks";
|
import {
|
||||||
import {
|
getPaymentMethodOptions,
|
||||||
getPaymentMethodOptions,
|
usePaymentMethodsListQuery,
|
||||||
usePaymentMethodsListQuery,
|
} from "@erp/catalogs/client/payment-methods";
|
||||||
} from "@erp/catalogs/client/payment-methods";
|
import {
|
||||||
import {
|
getPaymentTermOptions,
|
||||||
getPaymentTermOptions,
|
usePaymentTermsListQuery,
|
||||||
usePaymentTermsListQuery,
|
} from "@erp/catalogs/client/payment-terms";
|
||||||
} from "@erp/catalogs/client/payment-terms";
|
import { useReturnToNavigation } from "@erp/core/hooks";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createSearchParams, useNavigate, useParams } from "react-router-dom";
|
import { createSearchParams, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useDownloadProformaPDFController, useDownloadProformaPDFQuery } from "../../download-pdf";
|
import { useChangeProformaStatusDialogController } from "../../change-status";
|
||||||
import { useIssueProformaDialogController } from "../../issue-proforma";
|
import { useDownloadProformaPDFQuery } from "../../download-pdf";
|
||||||
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
|
import { useIssueProformaDialogController } from "../../issue-proforma";
|
||||||
import {
|
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
|
||||||
useDuplicateProformaByIdMutation,
|
import {
|
||||||
useProformaArchiveMutation,
|
useDuplicateProformaByIdMutation,
|
||||||
useProformaGetQuery,
|
useProformaArchiveMutation,
|
||||||
useProformaUnarchiveMutation,
|
useProformaGetQuery,
|
||||||
} from "../../shared";
|
useProformaUnarchiveMutation,
|
||||||
|
} from "../../shared";
|
||||||
const PROFORMAS_FALLBACK_PATH = "/proformas";
|
import type {
|
||||||
|
ProformaDetailActionKey,
|
||||||
const ACTIVE_ONLY_FILTERS = [
|
ProformaDetailActionsViewModel,
|
||||||
{
|
ProformaDetailDocumentState,
|
||||||
field: "isActive",
|
ProformaDetailDocumentViewModel,
|
||||||
operator: "EQUALS" as const,
|
ProformaDetailGeneralViewModel,
|
||||||
value: "true",
|
ProformaDetailHeaderViewModel,
|
||||||
},
|
ProformaDetailPageState,
|
||||||
];
|
} from "../entities";
|
||||||
|
import {
|
||||||
export const useProformaDetailPageController = () => {
|
buildProformaDetailActions,
|
||||||
const { t } = useTranslation();
|
buildProformaDetailDocumentViewModel,
|
||||||
const navigate = useNavigate();
|
buildProformaDetailGeneralViewModel,
|
||||||
const { id = "" } = useParams<{ id: string }>();
|
buildProformaDetailHeaderViewModel,
|
||||||
const [previewZoom, setPreviewZoom] = useState("fit");
|
} from "../utils";
|
||||||
|
|
||||||
const { currentReturnTo, navigateBack } = useReturnToNavigation({
|
const PROFORMAS_FALLBACK_PATH = "/proformas";
|
||||||
fallbackPath: PROFORMAS_FALLBACK_PATH,
|
const INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g;
|
||||||
});
|
|
||||||
|
const ACTIVE_ONLY_FILTERS = [
|
||||||
const proformaQuery = useProformaGetQuery({
|
{
|
||||||
id,
|
field: "isActive",
|
||||||
enabled: Boolean(id),
|
operator: "EQUALS" as const,
|
||||||
});
|
value: "true",
|
||||||
|
},
|
||||||
const issueDialogCtrl = useIssueProformaDialogController();
|
];
|
||||||
const downloadCtrl = useDownloadProformaPDFController();
|
|
||||||
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
|
const isUsablePdfBlob = (value: Blob | undefined): value is Blob => {
|
||||||
enabled: Boolean(id),
|
return value instanceof Blob && value.size > 0;
|
||||||
});
|
};
|
||||||
const duplicateMutation = useDuplicateProformaByIdMutation();
|
|
||||||
const archiveMutation = useProformaArchiveMutation();
|
const buildProformaPdfFilename = (reference: string) => {
|
||||||
const unarchiveMutation = useProformaUnarchiveMutation();
|
const sanitizedReference = reference
|
||||||
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
|
.trim()
|
||||||
|
.replace(INVALID_FILENAME_CHARACTERS, "-")
|
||||||
useEffect(() => {
|
.replace(/\s+/g, "-")
|
||||||
if (!previewPdfQuery.data) {
|
.replace(/-+/g, "-")
|
||||||
setPreviewPdfUrl(null);
|
.replace(/^-|-$/g, "");
|
||||||
return;
|
|
||||||
}
|
return `proforma-${sanitizedReference || "documento"}.pdf`;
|
||||||
|
};
|
||||||
const objectUrl = URL.createObjectURL(previewPdfQuery.data);
|
|
||||||
setPreviewPdfUrl(objectUrl);
|
const triggerBlobDownload = (blob: Blob, filename: string) => {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
return () => {
|
const anchor = document.createElement("a");
|
||||||
URL.revokeObjectURL(objectUrl);
|
anchor.href = objectUrl;
|
||||||
};
|
anchor.download = filename;
|
||||||
}, [previewPdfQuery.data]);
|
anchor.click();
|
||||||
|
|
||||||
const paymentMethodsQuery = usePaymentMethodsListQuery({
|
window.setTimeout(() => {
|
||||||
criteria: {
|
URL.revokeObjectURL(objectUrl);
|
||||||
pageSize: 999,
|
}, 0);
|
||||||
filters: ACTIVE_ONLY_FILTERS,
|
};
|
||||||
},
|
|
||||||
});
|
export const useProformaDetailPageController = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const paymentTermsQuery = usePaymentTermsListQuery({
|
const navigate = useNavigate();
|
||||||
criteria: {
|
const { id = "" } = useParams<{ id: string }>();
|
||||||
pageSize: 999,
|
|
||||||
filters: ACTIVE_ONLY_FILTERS,
|
const { currentReturnTo, navigateBack } = useReturnToNavigation({
|
||||||
},
|
fallbackPath: PROFORMAS_FALLBACK_PATH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paymentMethodOptions = useMemo(() => {
|
const proformaQuery = useProformaGetQuery({
|
||||||
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
|
id,
|
||||||
}, [paymentMethodsQuery.data?.items]);
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
const paymentTermOptions = useMemo(() => {
|
|
||||||
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
|
const issueDialogCtrl = useIssueProformaDialogController();
|
||||||
}, [paymentTermsQuery.data?.items]);
|
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
|
||||||
|
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
|
||||||
const proforma = proformaQuery.data;
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
const paymentMethodLabel =
|
const duplicateMutation = useDuplicateProformaByIdMutation();
|
||||||
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
|
const archiveMutation = useProformaArchiveMutation();
|
||||||
null;
|
const unarchiveMutation = useProformaUnarchiveMutation();
|
||||||
|
const previewPdfBlob = isUsablePdfBlob(previewPdfQuery.data) ? previewPdfQuery.data : null;
|
||||||
const paymentTermLabel =
|
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
|
||||||
paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null;
|
|
||||||
|
useEffect(() => {
|
||||||
const handleEdit = () => {
|
if (!previewPdfBlob) {
|
||||||
if (!proforma) {
|
setPreviewPdfUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate({
|
const objectUrl = URL.createObjectURL(previewPdfBlob);
|
||||||
pathname: `/proformas/${proforma.id}/edit`,
|
setPreviewPdfUrl(objectUrl);
|
||||||
search: createSearchParams({
|
|
||||||
returnTo: currentReturnTo,
|
return () => {
|
||||||
}).toString(),
|
URL.revokeObjectURL(objectUrl);
|
||||||
});
|
};
|
||||||
};
|
}, [previewPdfBlob]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const paymentMethodsQuery = usePaymentMethodsListQuery({
|
||||||
if (!proforma) {
|
criteria: {
|
||||||
return;
|
pageSize: 999,
|
||||||
}
|
filters: ACTIVE_ONLY_FILTERS,
|
||||||
|
},
|
||||||
downloadCtrl.download(proforma.id, proforma.proformaReference);
|
});
|
||||||
};
|
|
||||||
|
const paymentTermsQuery = usePaymentTermsListQuery({
|
||||||
const handleOpenPreviewPdf = () => {
|
criteria: {
|
||||||
if (previewPdfUrl) {
|
pageSize: 999,
|
||||||
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
|
filters: ACTIVE_ONLY_FILTERS,
|
||||||
return;
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
handleDownload();
|
const paymentMethodOptions = useMemo(() => {
|
||||||
};
|
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
|
||||||
|
}, [paymentMethodsQuery.data?.items]);
|
||||||
const handlePrintPreview = () => {
|
|
||||||
handleOpenPreviewPdf();
|
const paymentTermOptions = useMemo(() => {
|
||||||
};
|
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
|
||||||
|
}, [paymentTermsQuery.data?.items]);
|
||||||
const handleDuplicate = async () => {
|
|
||||||
if (!proforma) {
|
const proforma = proformaQuery.data;
|
||||||
return;
|
const paymentMethodLabel =
|
||||||
}
|
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
|
||||||
|
null;
|
||||||
try {
|
|
||||||
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
|
const paymentTermLabel =
|
||||||
|
paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null;
|
||||||
showSuccessToast(
|
|
||||||
t("proformas.duplicate.success_title"),
|
const paymentContext = useMemo(
|
||||||
duplicated.proformaReference
|
() => ({
|
||||||
? t("proformas.duplicate.success_with_reference", {
|
translate: (key: string, params?: Record<string, string | number | null | undefined>) =>
|
||||||
reference: duplicated.proformaReference,
|
t(key, params),
|
||||||
})
|
paymentMethodLabel,
|
||||||
: t("proformas.duplicate.success_description")
|
paymentTermLabel,
|
||||||
);
|
}),
|
||||||
|
[paymentMethodLabel, paymentTermLabel, t]
|
||||||
navigate({
|
);
|
||||||
pathname: `/proformas/${duplicated.id}/edit`,
|
|
||||||
search: createSearchParams({
|
const state: ProformaDetailPageState = {
|
||||||
returnTo: currentReturnTo,
|
isInitialLoading: proformaQuery.isLoading,
|
||||||
}).toString(),
|
isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading,
|
||||||
});
|
isError: proformaQuery.isError,
|
||||||
} catch (error) {
|
isNotFound: !(proformaQuery.isLoading || proformaQuery.isError || proforma),
|
||||||
const message =
|
errorMessage: proformaQuery.isError
|
||||||
error instanceof Error
|
? proformaQuery.error instanceof Error
|
||||||
? error.message
|
? proformaQuery.error.message
|
||||||
: t("proformas.duplicate.error_unknown_message");
|
: t("pages.proformas.update.load_error.message")
|
||||||
|
: null,
|
||||||
showErrorToast(t("proformas.duplicate.error_title"), message);
|
};
|
||||||
}
|
|
||||||
};
|
const documentState: ProformaDetailDocumentState = {
|
||||||
|
isInitialLoading: previewPdfQuery.isLoading && !previewPdfBlob,
|
||||||
const handleArchiveToggle = async () => {
|
isFetching: previewPdfQuery.isFetching && !previewPdfQuery.isLoading,
|
||||||
if (!proforma) {
|
isError: previewPdfQuery.isError,
|
||||||
return;
|
errorMessage: previewPdfQuery.isError
|
||||||
}
|
? previewPdfQuery.error instanceof Error
|
||||||
|
? previewPdfQuery.error.message
|
||||||
try {
|
: t("proformas.detail.document.error")
|
||||||
if (proforma.isArchived) {
|
: null,
|
||||||
await unarchiveMutation.mutateAsync({ proformaId: proforma.id });
|
};
|
||||||
|
|
||||||
showSuccessToast("Proforma desarchivada", "La proforma vuelve a estar disponible.");
|
const handleEdit = () => {
|
||||||
} else {
|
if (!proforma) {
|
||||||
await archiveMutation.mutateAsync({ proformaId: proforma.id });
|
return;
|
||||||
|
}
|
||||||
showSuccessToast("Proforma archivada", "La proforma se ha movido al archivo.");
|
|
||||||
}
|
navigate({
|
||||||
|
pathname: `/proformas/${proforma.id}/edit`,
|
||||||
await proformaQuery.refetch();
|
search: createSearchParams({
|
||||||
} catch (error) {
|
returnTo: currentReturnTo,
|
||||||
showErrorToast(
|
}).toString(),
|
||||||
proforma.isArchived ? "No se pudo desarchivar" : "No se pudo archivar",
|
});
|
||||||
error instanceof Error ? error.message : "Inténtalo de nuevo más tarde."
|
};
|
||||||
);
|
|
||||||
}
|
const handleDownload = async () => {
|
||||||
};
|
if (!proforma) {
|
||||||
|
return;
|
||||||
const primaryAction = useMemo(() => {
|
}
|
||||||
if (!proforma) {
|
|
||||||
return {
|
const filename = buildProformaPdfFilename(proforma.proformaReference);
|
||||||
label: "Editar",
|
|
||||||
onClick: () => undefined,
|
if (previewPdfBlob) {
|
||||||
};
|
triggerBlobDownload(previewPdfBlob, filename);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (proforma.isArchived) {
|
|
||||||
return {
|
const result = await previewPdfQuery.refetch();
|
||||||
label: "Desarchivar",
|
|
||||||
onClick: handleArchiveToggle,
|
if (result.error) {
|
||||||
};
|
showErrorToast(
|
||||||
}
|
t("proformas.detail.document.error_title"),
|
||||||
|
result.error instanceof Error ? result.error.message : t("proformas.detail.document.error")
|
||||||
if (proforma.status === "approved") {
|
);
|
||||||
return {
|
return;
|
||||||
label: "Emitir factura",
|
}
|
||||||
onClick: () => issueDialogCtrl.openDialog(prepareIssueProformaTarget(proforma)),
|
|
||||||
};
|
if (!isUsablePdfBlob(result.data)) {
|
||||||
}
|
showErrorToast(
|
||||||
|
t("proformas.detail.document.empty_title"),
|
||||||
return {
|
t("proformas.detail.document.empty_description")
|
||||||
label: "Editar",
|
);
|
||||||
onClick: handleEdit,
|
return;
|
||||||
};
|
}
|
||||||
}, [handleArchiveToggle, issueDialogCtrl, proforma]);
|
|
||||||
|
triggerBlobDownload(result.data, filename);
|
||||||
return {
|
};
|
||||||
id,
|
|
||||||
proforma,
|
const handleOpenCustomer = () => {
|
||||||
proformaQuery,
|
if (!proforma?.customerId) {
|
||||||
paymentMethodLabel,
|
return;
|
||||||
paymentTermLabel,
|
}
|
||||||
previewZoom,
|
|
||||||
setPreviewZoom,
|
navigate({
|
||||||
previewPdfQuery,
|
pathname: `/customers/${proforma.customerId}`,
|
||||||
previewPdfUrl,
|
search: createSearchParams({
|
||||||
currentReturnTo,
|
returnTo: currentReturnTo,
|
||||||
navigateBack,
|
}).toString(),
|
||||||
primaryAction,
|
});
|
||||||
issueDialogCtrl,
|
};
|
||||||
downloadCtrl,
|
|
||||||
duplicateMutation,
|
const handleOpenPreviewPdf = () => {
|
||||||
archiveMutation,
|
if (!previewPdfUrl) {
|
||||||
unarchiveMutation,
|
return;
|
||||||
handleEdit,
|
}
|
||||||
handleDownload,
|
|
||||||
handleOpenPreviewPdf,
|
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
|
||||||
handlePrintPreview,
|
};
|
||||||
handleDuplicate,
|
|
||||||
handleArchiveToggle,
|
const handlePrintPreview = () => {
|
||||||
};
|
if (!previewPdfUrl) {
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetryDocument = async () => {
|
||||||
|
await previewPdfQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
if (!proforma) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
|
||||||
|
|
||||||
|
showSuccessToast(
|
||||||
|
t("proformas.duplicate.success_title"),
|
||||||
|
duplicated.proformaReference
|
||||||
|
? t("proformas.duplicate.success_with_reference", {
|
||||||
|
reference: duplicated.proformaReference,
|
||||||
|
})
|
||||||
|
: t("proformas.duplicate.success_description")
|
||||||
|
);
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
pathname: `/proformas/${duplicated.id}/edit`,
|
||||||
|
search: createSearchParams({
|
||||||
|
returnTo: currentReturnTo,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : t("proformas.duplicate.error_unknown_message");
|
||||||
|
|
||||||
|
showErrorToast(t("proformas.duplicate.error_title"), message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveToggle = async () => {
|
||||||
|
if (!proforma) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (proforma.isArchived) {
|
||||||
|
await unarchiveMutation.mutateAsync({ proformaId: proforma.id });
|
||||||
|
|
||||||
|
showSuccessToast("Proforma desarchivada", "La proforma vuelve a estar disponible.");
|
||||||
|
} else {
|
||||||
|
await archiveMutation.mutateAsync({ proformaId: proforma.id });
|
||||||
|
|
||||||
|
showSuccessToast("Proforma archivada", "La proforma se ha movido al archivo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await proformaQuery.refetch();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(
|
||||||
|
proforma.isArchived ? "No se pudo desarchivar" : "No se pudo archivar",
|
||||||
|
error instanceof Error ? error.message : "Inténtalo de nuevo más tarde."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const header: ProformaDetailHeaderViewModel | null = proforma
|
||||||
|
? buildProformaDetailHeaderViewModel(proforma, paymentContext)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const document: ProformaDetailDocumentViewModel | null = proforma
|
||||||
|
? buildProformaDetailDocumentViewModel(previewPdfUrl, paymentContext)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const general: ProformaDetailGeneralViewModel | null = proforma
|
||||||
|
? buildProformaDetailGeneralViewModel(proforma, paymentContext)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const actions: ProformaDetailActionsViewModel = useMemo(() => {
|
||||||
|
if (!proforma) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildProformaDetailActions({
|
||||||
|
status: proforma.status,
|
||||||
|
isArchived: proforma.isArchived,
|
||||||
|
labels: {
|
||||||
|
edit: t("pages.proformas.list.actions.edit"),
|
||||||
|
changeStatus: t("pages.proformas.list.actions.change_status"),
|
||||||
|
issue: t("pages.proformas.list.actions.issue"),
|
||||||
|
duplicate: t("pages.proformas.list.actions.duplicate"),
|
||||||
|
downloadPdf: t("pages.proformas.list.actions.download_pdf"),
|
||||||
|
archive: t("pages.proformas.list.actions.archive"),
|
||||||
|
unarchive: t("pages.proformas.list.actions.unarchive"),
|
||||||
|
disabledReasons: {
|
||||||
|
archivedOnly: t("pages.proformas.list.actions.disabled_reasons.archived_only"),
|
||||||
|
archiveConflict: t("proformas.archive.error_conflict_message"),
|
||||||
|
editIssued: t("pages.proformas.list.actions.disabled_reasons.edit_issued"),
|
||||||
|
editArchived: t("proformas.detail.actions.disabled_reasons.edit_archived"),
|
||||||
|
statusIssued: t("pages.proformas.list.actions.disabled_reasons.status_issued"),
|
||||||
|
statusArchived: t("proformas.detail.actions.disabled_reasons.status_archived"),
|
||||||
|
issueNotAllowed: t("proformas.issue_proforma_dialog.not_allowed_description"),
|
||||||
|
operationInProgress: t(
|
||||||
|
"pages.proformas.list.actions.disabled_reasons.operation_in_progress"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
issue: issueDialogCtrl.isSubmitting,
|
||||||
|
duplicate: duplicateMutation.isPending,
|
||||||
|
"download-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
|
||||||
|
archive: archiveMutation.isPending,
|
||||||
|
unarchive: unarchiveMutation.isPending,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
archiveMutation.isPending,
|
||||||
|
duplicateMutation.isPending,
|
||||||
|
issueDialogCtrl.isSubmitting,
|
||||||
|
previewPdfBlob,
|
||||||
|
previewPdfQuery.isFetching,
|
||||||
|
proforma,
|
||||||
|
t,
|
||||||
|
unarchiveMutation.isPending,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const executeAction = (action: ProformaDetailActionKey) => {
|
||||||
|
if (!proforma) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "edit":
|
||||||
|
handleEdit();
|
||||||
|
return;
|
||||||
|
case "change-status":
|
||||||
|
changeStatusDialogCtrl.openDialog([
|
||||||
|
{
|
||||||
|
id: proforma.id,
|
||||||
|
reference: proforma.reference!,
|
||||||
|
status: proforma.status,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
case "issue":
|
||||||
|
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proforma));
|
||||||
|
return;
|
||||||
|
case "duplicate":
|
||||||
|
handleDuplicate();
|
||||||
|
return;
|
||||||
|
case "download-pdf":
|
||||||
|
handleDownload();
|
||||||
|
return;
|
||||||
|
case "archive":
|
||||||
|
case "unarchive":
|
||||||
|
handleArchiveToggle();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
documentState,
|
||||||
|
header,
|
||||||
|
document,
|
||||||
|
general,
|
||||||
|
actions,
|
||||||
|
navigation: {
|
||||||
|
currentReturnTo,
|
||||||
|
navigateBack,
|
||||||
|
openEdit: handleEdit,
|
||||||
|
openCustomer: handleOpenCustomer,
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
executeAction,
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
issue: issueDialogCtrl,
|
||||||
|
changeStatus: changeStatusDialogCtrl,
|
||||||
|
},
|
||||||
|
viewer: {
|
||||||
|
handleDownload,
|
||||||
|
handleOpenPreviewPdf,
|
||||||
|
handlePrintPreview,
|
||||||
|
handleRetryDocument,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./proforma-detail-view-models.entity";
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
import type { ProformaStatus } from "../../shared";
|
||||||
|
|
||||||
|
export interface ProformaDetailPageState {
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
isRefetching: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isNotFound: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailHeaderViewModel {
|
||||||
|
title: string;
|
||||||
|
customerName: string;
|
||||||
|
documentReference: string;
|
||||||
|
status: ProformaStatus;
|
||||||
|
statusLabel: string;
|
||||||
|
isArchived: boolean;
|
||||||
|
archivedLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailDocumentViewModel {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
src: string | null;
|
||||||
|
mode: "pdf";
|
||||||
|
capabilities: {
|
||||||
|
canOpen: boolean;
|
||||||
|
canDownload: boolean;
|
||||||
|
canPrint: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailDocumentState {
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailSummaryViewModel {
|
||||||
|
totalLabel: string;
|
||||||
|
documentReference: string;
|
||||||
|
statusLabel: string;
|
||||||
|
currencyCode: string;
|
||||||
|
lineCount: number;
|
||||||
|
totalUnitsLabel: string | null;
|
||||||
|
globalDiscountLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailCustomerViewModel {
|
||||||
|
customerId: string;
|
||||||
|
name: string;
|
||||||
|
tin: string | null;
|
||||||
|
addressLines: string[];
|
||||||
|
canOpenCustomer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailDatesViewModel {
|
||||||
|
proformaDateLabel: string;
|
||||||
|
operationDateLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailCommercialViewModel {
|
||||||
|
paymentMethodLabel: string | null;
|
||||||
|
paymentTermLabel: string | null;
|
||||||
|
languageLabel: string;
|
||||||
|
internalReferenceLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailFiscalBreakdownItemViewModel {
|
||||||
|
key: string;
|
||||||
|
taxLabel: string;
|
||||||
|
taxableAmountLabel: string;
|
||||||
|
taxAmountLabel: string;
|
||||||
|
recLabel: string | null;
|
||||||
|
recAmountLabel: string | null;
|
||||||
|
retentionLabel: string | null;
|
||||||
|
retentionAmountLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailFiscalViewModel {
|
||||||
|
taxRegimeLabel: string | null;
|
||||||
|
taxModeLabel: string;
|
||||||
|
subtotalLabel: string;
|
||||||
|
discountLabel: string;
|
||||||
|
taxableAmountLabel: string;
|
||||||
|
taxAmountLabel: string;
|
||||||
|
recAmountLabel: string | null;
|
||||||
|
retentionAmountLabel: string | null;
|
||||||
|
totalAmountLabel: string;
|
||||||
|
breakdown: ProformaDetailFiscalBreakdownItemViewModel[];
|
||||||
|
usesRec: boolean;
|
||||||
|
usesRetention: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailContentViewModel {
|
||||||
|
description: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProformaDetailLinkedInvoiceState =
|
||||||
|
| "not-issued"
|
||||||
|
| "ready-to-issue"
|
||||||
|
| "issued-without-link"
|
||||||
|
| "issued-linked-without-detail-route";
|
||||||
|
|
||||||
|
export interface ProformaDetailLinkedInvoiceViewModel {
|
||||||
|
state: ProformaDetailLinkedInvoiceState;
|
||||||
|
linkedInvoiceId: string | null;
|
||||||
|
primaryText: string;
|
||||||
|
secondaryText: string | null;
|
||||||
|
canOpenLinkedInvoice: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailGeneralViewModel {
|
||||||
|
summary: ProformaDetailSummaryViewModel;
|
||||||
|
customer: ProformaDetailCustomerViewModel;
|
||||||
|
dates: ProformaDetailDatesViewModel;
|
||||||
|
commercial: ProformaDetailCommercialViewModel;
|
||||||
|
fiscal: ProformaDetailFiscalViewModel;
|
||||||
|
content: ProformaDetailContentViewModel;
|
||||||
|
linkedInvoice: ProformaDetailLinkedInvoiceViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProformaDetailActionKey =
|
||||||
|
| "edit"
|
||||||
|
| "change-status"
|
||||||
|
| "issue"
|
||||||
|
| "duplicate"
|
||||||
|
| "download-pdf"
|
||||||
|
| "archive"
|
||||||
|
| "unarchive";
|
||||||
|
|
||||||
|
export type ProformaDetailActionPlacement = "primary" | "secondary" | "menu";
|
||||||
|
|
||||||
|
export interface ProformaDetailActionViewModel {
|
||||||
|
key: ProformaDetailActionKey;
|
||||||
|
label: string;
|
||||||
|
visibility: "visible" | "hidden";
|
||||||
|
enabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
disabledReason: string | null;
|
||||||
|
placement: ProformaDetailActionPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaDetailActionsViewModel {
|
||||||
|
items: ProformaDetailActionViewModel[];
|
||||||
|
}
|
||||||
@ -1,2 +1,4 @@
|
|||||||
export * from "./controllers";
|
export * from "./controllers";
|
||||||
|
export * from "./entities";
|
||||||
export * from "./ui";
|
export * from "./ui";
|
||||||
|
export * from "./utils";
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
export * from "./proforma-detail-header-actions";
|
export * from "./proforma-detail-header-actions";
|
||||||
export * from "./proforma-detail-preview-card";
|
export * from "./proforma-detail-content";
|
||||||
export * from "./proforma-detail-sidebar";
|
export * from "./proforma-detail-header";
|
||||||
export * from "./proforma-detail-summary-card";
|
export * from "./proforma-detail-inspector";
|
||||||
|
export * from "./proforma-general-panel";
|
||||||
|
export * from "./proforma-document-pane";
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type ProformaDetailContentProps = {
|
||||||
|
documentPane: ReactNode;
|
||||||
|
inspector: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaDetailContent = ({ documentPane, inspector }: ProformaDetailContentProps) => {
|
||||||
|
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:overflow-hidden lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
{documentPane}
|
||||||
|
{inspector}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,57 +1,124 @@
|
|||||||
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@repo/shadcn-ui/components";
|
import {
|
||||||
import { DownloadIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon } from "lucide-react";
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import {
|
||||||
|
ArchiveIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FileOutputIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
TagsIcon,
|
||||||
|
Undo2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailActionKey, ProformaDetailActionsViewModel } from "../../entities";
|
||||||
|
|
||||||
type ProformaDetailHeaderActionsProps = {
|
type ProformaDetailHeaderActionsProps = {
|
||||||
primaryAction: {
|
actions: ProformaDetailActionsViewModel;
|
||||||
label: string;
|
onExecuteAction: (action: ProformaDetailActionKey) => void;
|
||||||
onClick: () => void;
|
|
||||||
};
|
|
||||||
isPrimaryLoading: boolean;
|
|
||||||
isArchived: boolean;
|
|
||||||
onDownload: () => void;
|
|
||||||
onDuplicate: () => void;
|
|
||||||
onArchiveToggle: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProformaDetailHeaderActions = ({
|
export const ProformaDetailHeaderActions = ({
|
||||||
primaryAction,
|
actions,
|
||||||
isPrimaryLoading,
|
onExecuteAction,
|
||||||
isArchived,
|
|
||||||
onDownload,
|
|
||||||
onDuplicate,
|
|
||||||
onArchiveToggle,
|
|
||||||
}: ProformaDetailHeaderActionsProps) => {
|
}: ProformaDetailHeaderActionsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const visibleItems = actions.items.filter(
|
||||||
|
(item) => item.visibility === "visible" && item.key !== "download-pdf"
|
||||||
|
);
|
||||||
|
const primaryAction = visibleItems.find((item) => item.placement === "primary") ?? null;
|
||||||
|
const secondaryActions = visibleItems.filter((item) => item.placement === "secondary");
|
||||||
|
const menuActions = visibleItems.filter((item) => item.placement === "menu");
|
||||||
|
|
||||||
|
const getActionIcon = (action: ProformaDetailActionKey) => {
|
||||||
|
switch (action) {
|
||||||
|
case "change-status":
|
||||||
|
return TagsIcon;
|
||||||
|
case "issue":
|
||||||
|
return FileOutputIcon;
|
||||||
|
case "duplicate":
|
||||||
|
return CopyIcon;
|
||||||
|
case "download-pdf":
|
||||||
|
return DownloadIcon;
|
||||||
|
case "archive":
|
||||||
|
return ArchiveIcon;
|
||||||
|
case "unarchive":
|
||||||
|
return Undo2Icon;
|
||||||
|
case "edit":
|
||||||
|
default:
|
||||||
|
return PencilIcon;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const PrimaryIcon = primaryAction ? getActionIcon(primaryAction.key) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||||
<Button disabled={isPrimaryLoading} onClick={primaryAction.onClick} type="button">
|
{primaryAction ? (
|
||||||
{isPrimaryLoading ? (
|
<Button
|
||||||
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
|
disabled={!primaryAction.enabled}
|
||||||
) : (
|
onClick={() => onExecuteAction(primaryAction.key)}
|
||||||
<PencilIcon className="mr-2 size-4" />
|
type="button"
|
||||||
)}
|
>
|
||||||
{primaryAction.label}
|
{primaryAction.loading ? (
|
||||||
</Button>
|
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
|
||||||
|
) : PrimaryIcon ? (
|
||||||
|
<PrimaryIcon className="mr-2 size-4" />
|
||||||
|
) : null}
|
||||||
|
{primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button onClick={onDownload} type="button" variant="outline">
|
{secondaryActions.map((action) => {
|
||||||
<DownloadIcon className="mr-2 size-4" />
|
const Icon = getActionIcon(action.key);
|
||||||
Descargar PDF
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
return (
|
||||||
<DropdownMenuTrigger
|
<Button
|
||||||
render={
|
disabled={!action.enabled}
|
||||||
<Button size="icon" type="button" variant="outline">
|
key={action.key}
|
||||||
<MoreHorizontalIcon className="size-4" />
|
onClick={() => onExecuteAction(action.key)}
|
||||||
</Button>
|
type="button"
|
||||||
}
|
variant="outline"
|
||||||
/>
|
>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<Icon className="mr-2 size-4" />
|
||||||
<DropdownMenuItem onClick={onDuplicate}>Duplicar proforma</DropdownMenuItem>
|
{action.label}
|
||||||
<DropdownMenuItem onClick={onArchiveToggle}>
|
</Button>
|
||||||
{isArchived ? "Desarchivar" : "Archivar"}
|
);
|
||||||
</DropdownMenuItem>
|
})}
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import { ProformaStatusBadge } from "../../../list/ui/components";
|
||||||
|
import { ProformaPageHeader } from "../../../shared";
|
||||||
|
import type {
|
||||||
|
ProformaDetailActionKey,
|
||||||
|
ProformaDetailActionsViewModel,
|
||||||
|
ProformaDetailHeaderViewModel,
|
||||||
|
} from "../../entities";
|
||||||
|
import { ProformaDetailArchivedBadge, ProformaDetailHeaderMeta } from "../components";
|
||||||
|
|
||||||
|
import { ProformaDetailHeaderActions } from "./proforma-detail-header-actions";
|
||||||
|
|
||||||
|
type ProformaDetailHeaderProps = {
|
||||||
|
header: ProformaDetailHeaderViewModel;
|
||||||
|
actions: ProformaDetailActionsViewModel;
|
||||||
|
onBack: () => void;
|
||||||
|
onExecuteAction: (action: ProformaDetailActionKey) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaDetailHeader = ({
|
||||||
|
header,
|
||||||
|
actions,
|
||||||
|
onBack,
|
||||||
|
onExecuteAction,
|
||||||
|
}: ProformaDetailHeaderProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaPageHeader
|
||||||
|
actions={<ProformaDetailHeaderActions actions={actions} onExecuteAction={onExecuteAction} />}
|
||||||
|
backLabel={t("pages.proformas.create.back_to_list")}
|
||||||
|
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">
|
||||||
|
<ProformaStatusBadge className="text-sm" status={header.status} />
|
||||||
|
{header.archivedLabel ? (
|
||||||
|
<ProformaDetailArchivedBadge label={header.archivedLabel} />
|
||||||
|
) : null}
|
||||||
|
<ProformaDetailHeaderMeta
|
||||||
|
customerName={header.customerName}
|
||||||
|
documentReference={header.documentReference}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProformaPageHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { ScrollArea, Separator } from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||||
|
|
||||||
|
import { ProformaGeneralPanel } from "./proforma-general-panel";
|
||||||
|
|
||||||
|
type ProformaDetailInspectorProps = {
|
||||||
|
general: ProformaDetailGeneralViewModel;
|
||||||
|
onOpenCustomer: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaDetailInspector = ({
|
||||||
|
general,
|
||||||
|
onOpenCustomer,
|
||||||
|
}: ProformaDetailInspectorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label={t("proformas.detail.regions.details")}
|
||||||
|
className="overflow-visible bg-background lg:min-h-0 lg:overflow-hidden lg:border-l"
|
||||||
|
>
|
||||||
|
<ScrollArea className="h-auto lg:h-full">
|
||||||
|
<div className="space-y-4 p-4 sm:p-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-sm font-semibold tracking-tight text-foreground">
|
||||||
|
{t("proformas.detail.regions.details")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("pages.proformas.view.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ProformaGeneralPanel general={general} onOpenCustomer={onOpenCustomer} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { PDFViewer } from "@repo/rdx-ui/components";
|
|
||||||
|
|
||||||
type ProformaDetailPreviewCardProps = {
|
|
||||||
src: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
onOpen: () => void;
|
|
||||||
onDownload: () => void;
|
|
||||||
onPrint: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailPreviewCard = ({
|
|
||||||
src,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
onOpen,
|
|
||||||
onDownload,
|
|
||||||
onPrint,
|
|
||||||
}: ProformaDetailPreviewCardProps) => {
|
|
||||||
return (
|
|
||||||
<PDFViewer
|
|
||||||
className="h-[calc(100vh-11.5rem)] min-h-[720px]"
|
|
||||||
defaultZoom="fit"
|
|
||||||
error={error}
|
|
||||||
loading={isLoading}
|
|
||||||
onDownload={onDownload}
|
|
||||||
onOpen={onOpen}
|
|
||||||
onPrint={onPrint}
|
|
||||||
src={src}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import type { Proforma } from "../../../shared";
|
|
||||||
import { ProformaDetailStatusInspectorCard } from "../components";
|
|
||||||
|
|
||||||
import { ProformaDetailSummaryCard } from "./proforma-detail-summary-card";
|
|
||||||
|
|
||||||
type ProformaDetailSidebarProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
paymentMethodLabel: string | null;
|
|
||||||
paymentTermLabel: string | null;
|
|
||||||
onEdit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailSidebar = ({
|
|
||||||
proforma,
|
|
||||||
paymentMethodLabel,
|
|
||||||
paymentTermLabel,
|
|
||||||
}: ProformaDetailSidebarProps) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ProformaDetailStatusInspectorCard proforma={proforma} />
|
|
||||||
<ProformaDetailSummaryCard
|
|
||||||
paymentMethodLabel={paymentMethodLabel}
|
|
||||||
paymentTermLabel={paymentTermLabel}
|
|
||||||
proforma={proforma}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import { FieldValueList, FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
|
|
||||||
import { Separator } from "@repo/shadcn-ui/components";
|
|
||||||
import { LandmarkIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { type Proforma } from "../../../shared";
|
|
||||||
import { formatProformaDate, formatProformaMoney } from "../components/proforma-detail-formatters";
|
|
||||||
|
|
||||||
type ProformaDetailSummaryCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
paymentMethodLabel: string | null;
|
|
||||||
paymentTermLabel: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SectionHeading = ({ title, description }: { title: string; description?: string }) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
|
||||||
{description ? <p className="text-xs leading-5 text-muted-foreground">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailSummaryCard = ({
|
|
||||||
proforma,
|
|
||||||
paymentMethodLabel,
|
|
||||||
paymentTermLabel,
|
|
||||||
}: ProformaDetailSummaryCardProps) => {
|
|
||||||
const billingItems = [
|
|
||||||
{
|
|
||||||
label: "Método de pago",
|
|
||||||
value: paymentMethodLabel ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Plazo",
|
|
||||||
value: paymentTermLabel ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Moneda",
|
|
||||||
value: proforma.currencyCode,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const datesItems = [
|
|
||||||
{
|
|
||||||
label: "Fecha proforma",
|
|
||||||
value: formatProformaDate(proforma.proformaDate),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Fecha operación",
|
|
||||||
value: formatProformaDate(proforma.operationDate),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Serie",
|
|
||||||
value: proforma.series ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Referencia",
|
|
||||||
value: proforma.reference ?? "\u2014",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalsItems = [
|
|
||||||
{
|
|
||||||
label: "Subtotal",
|
|
||||||
value: formatProformaMoney(proforma.subtotalAmount, proforma.currencyCode, proforma.languageCode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Descuentos",
|
|
||||||
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
|
|
||||||
valueClassName: "text-red-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "IVA",
|
|
||||||
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Total",
|
|
||||||
value: formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode),
|
|
||||||
valueClassName: "text-lg font-semibold text-foreground",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormSectionCard
|
|
||||||
className="shadow-sm"
|
|
||||||
contentClassName="space-y-5"
|
|
||||||
description="Resumen operativo de la proforma con las condiciones clave y sus importes."
|
|
||||||
icon={<LandmarkIcon className="size-5" />}
|
|
||||||
title="Resumen de proforma"
|
|
||||||
>
|
|
||||||
<FormSectionGrid className="gap-y-6 md:grid-cols-1">
|
|
||||||
<div className="space-y-3 md:col-span-12">
|
|
||||||
<SectionHeading
|
|
||||||
description="Condiciones de cobro configuradas actualmente para esta proforma."
|
|
||||||
title="Cobros"
|
|
||||||
/>
|
|
||||||
<FieldValueList
|
|
||||||
className="rounded-lg border bg-muted/20 p-4"
|
|
||||||
items={billingItems}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-12">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 md:col-span-12">
|
|
||||||
<SectionHeading
|
|
||||||
description="Fechas principales y referencias internas del documento."
|
|
||||||
title="Fechas y referencias"
|
|
||||||
/>
|
|
||||||
<FieldValueList
|
|
||||||
className="rounded-lg border bg-muted/20 p-4"
|
|
||||||
items={datesItems}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-12">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 md:col-span-12">
|
|
||||||
<SectionHeading
|
|
||||||
description="Texto libre que acompaña a la proforma y puede trasladarse al PDF."
|
|
||||||
title="Notas"
|
|
||||||
/>
|
|
||||||
<div className="rounded-lg border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-medium text-foreground">Descripción</p>
|
|
||||||
<p>{proforma.description ?? "Sin descripción."}</p>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-4" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-medium text-foreground">Observaciones</p>
|
|
||||||
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-12">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 md:col-span-12">
|
|
||||||
<SectionHeading
|
|
||||||
description="Desglose económico principal del documento."
|
|
||||||
title="Resumen económico"
|
|
||||||
/>
|
|
||||||
<FieldValueList
|
|
||||||
className="rounded-lg border bg-muted/20 p-4"
|
|
||||||
itemClassName="items-center"
|
|
||||||
items={totalsItems}
|
|
||||||
valueClassName="tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormSectionGrid>
|
|
||||||
</FormSectionCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
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 { useId } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities";
|
||||||
|
|
||||||
|
type ProformaDocumentPaneProps = {
|
||||||
|
document: ProformaDetailDocumentViewModel;
|
||||||
|
state: ProformaDetailDocumentState;
|
||||||
|
onDownload: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
onPrint: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaDocumentPane = ({
|
||||||
|
document,
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
onOpen,
|
||||||
|
onPrint,
|
||||||
|
onRetry,
|
||||||
|
}: ProformaDocumentPaneProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const headingId = useId();
|
||||||
|
const isRefreshing = state.isFetching && Boolean(document.src);
|
||||||
|
const shouldRenderBlockingError = state.isError && !document.src;
|
||||||
|
const shouldRenderStaleError = state.isError && Boolean(document.src);
|
||||||
|
|
||||||
|
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="h-full min-h-0 p-4 sm:p-6">
|
||||||
|
<h2 className="sr-only" id={headingId}>
|
||||||
|
{document.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{shouldRenderStaleError ? (
|
||||||
|
<Alert className="mb-4">
|
||||||
|
<AlertTitle>{t("proformas.detail.document.error_title")}</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<p>{state.errorMessage ?? t("proformas.detail.document.error")}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button onClick={onRetry} type="button" variant="outline">
|
||||||
|
{t("proformas.detail.document.retry")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PDFViewer
|
||||||
|
className={cn(
|
||||||
|
"min-h-[32rem] overflow-hidden border-border/80 bg-background shadow-none lg:h-full lg:min-h-full"
|
||||||
|
)}
|
||||||
|
defaultZoom="fit"
|
||||||
|
description={document.description}
|
||||||
|
error={shouldRenderBlockingError ? state.errorMessage : null}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
labels={{
|
||||||
|
download: t("proformas.detail.document.actions.download"),
|
||||||
|
emptyDescription: t("proformas.detail.document.empty_description"),
|
||||||
|
emptyTitle: t("proformas.detail.document.empty_title"),
|
||||||
|
errorTitle: t("proformas.detail.document.error_title"),
|
||||||
|
fit: t("proformas.detail.document.actions.fit"),
|
||||||
|
loading: t("proformas.detail.document.loading"),
|
||||||
|
open: t("proformas.detail.document.actions.open"),
|
||||||
|
print: t("proformas.detail.document.actions.print"),
|
||||||
|
refreshing: t("proformas.detail.document.refreshing"),
|
||||||
|
retry: t("proformas.detail.document.retry"),
|
||||||
|
zoomIn: t("proformas.detail.document.actions.zoom_in"),
|
||||||
|
zoomOut: t("proformas.detail.document.actions.zoom_out"),
|
||||||
|
zoomSelect: t("proformas.detail.document.actions.zoom_select"),
|
||||||
|
}}
|
||||||
|
loading={state.isInitialLoading}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onPrint={onPrint}
|
||||||
|
onRetry={onRetry}
|
||||||
|
src={document.src}
|
||||||
|
title={document.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||||
|
import {
|
||||||
|
ProformaCommercialSection,
|
||||||
|
ProformaCustomerSection,
|
||||||
|
ProformaFiscalSection,
|
||||||
|
ProformaLinkedInvoiceSection,
|
||||||
|
ProformaSummarySection,
|
||||||
|
} from "../sections";
|
||||||
|
|
||||||
|
export interface ProformaGeneralPanelProps {
|
||||||
|
general: ProformaDetailGeneralViewModel;
|
||||||
|
onOpenCustomer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformaGeneralPanel = ({
|
||||||
|
general,
|
||||||
|
onOpenCustomer,
|
||||||
|
}: ProformaGeneralPanelProps) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ProformaSummarySection
|
||||||
|
content={general.content}
|
||||||
|
dates={general.dates}
|
||||||
|
summary={general.summary}
|
||||||
|
/>
|
||||||
|
<ProformaCustomerSection customer={general.customer} onOpenCustomer={onOpenCustomer} />
|
||||||
|
<ProformaCommercialSection commercial={general.commercial} />
|
||||||
|
<ProformaFiscalSection fiscal={general.fiscal} />
|
||||||
|
<ProformaLinkedInvoiceSection linkedInvoice={general.linkedInvoice} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,9 +1,5 @@
|
|||||||
export * from "./proforma-detail-archived-badge";
|
export * from "./proforma-detail-archived-badge";
|
||||||
export * from "./proforma-detail-billing-inspector-card";
|
export * from "./proforma-detail-field";
|
||||||
export * from "./proforma-detail-dates-inspector-card";
|
|
||||||
export * from "./proforma-detail-header-meta";
|
export * from "./proforma-detail-header-meta";
|
||||||
export * from "./proforma-detail-inspector-card";
|
export * from "./proforma-detail-section";
|
||||||
export * from "./proforma-detail-notes-inspector-card";
|
|
||||||
export * from "./proforma-detail-skeleton";
|
export * from "./proforma-detail-skeleton";
|
||||||
export * from "./proforma-detail-status-inspector-card";
|
|
||||||
export * from "./proforma-detail-totals-inspector-card";
|
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
|
|||||||
import { ArchiveIcon } from "lucide-react";
|
import { ArchiveIcon } from "lucide-react";
|
||||||
|
|
||||||
type ProformaDetailArchivedBadgeProps = {
|
type ProformaDetailArchivedBadgeProps = {
|
||||||
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProformaDetailArchivedBadge = ({
|
export const ProformaDetailArchivedBadge = ({
|
||||||
|
label,
|
||||||
className,
|
className,
|
||||||
}: ProformaDetailArchivedBadgeProps) => {
|
}: ProformaDetailArchivedBadgeProps) => {
|
||||||
return (
|
return (
|
||||||
@ -21,7 +23,7 @@ export const ProformaDetailArchivedBadge = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArchiveIcon aria-hidden="true" />
|
<ArchiveIcon aria-hidden="true" />
|
||||||
Archivada
|
{label}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
|
||||||
|
|
||||||
type ProformaDetailBillingInspectorCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
paymentMethodLabel: string | null;
|
|
||||||
paymentTermLabel: string | null;
|
|
||||||
onEdit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailBillingInspectorCard = ({
|
|
||||||
proforma,
|
|
||||||
paymentMethodLabel,
|
|
||||||
paymentTermLabel,
|
|
||||||
onEdit,
|
|
||||||
}: ProformaDetailBillingInspectorCardProps) => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: "Método de pago",
|
|
||||||
value: paymentMethodLabel ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Plazo",
|
|
||||||
value: paymentTermLabel ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Moneda",
|
|
||||||
value: proforma.currencyCode,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Cobro">
|
|
||||||
<FieldValueList items={items} />
|
|
||||||
</ProformaDetailInspectorCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
import { formatProformaDate } from "./proforma-detail-formatters";
|
|
||||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
|
||||||
|
|
||||||
type ProformaDetailDatesInspectorCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
onEdit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailDatesInspectorCard = ({
|
|
||||||
proforma,
|
|
||||||
onEdit,
|
|
||||||
}: ProformaDetailDatesInspectorCardProps) => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: "Fecha proforma",
|
|
||||||
value: formatProformaDate(proforma.proformaDate),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Fecha operación",
|
|
||||||
value: formatProformaDate(proforma.operationDate),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Serie de la factura",
|
|
||||||
value: proforma.series ?? "\u2014",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Referencia interna",
|
|
||||||
value: proforma.reference ?? "\u2014",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Fechas y referencia">
|
|
||||||
<FieldValueList items={items} />
|
|
||||||
</ProformaDetailInspectorCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface ProformaDetailFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformaDetailField = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClassName,
|
||||||
|
}: ProformaDetailFieldProps) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,25 +1,21 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
import { formatProformaDate, formatProformaMoney } from "./proforma-detail-formatters";
|
|
||||||
|
|
||||||
type ProformaDetailHeaderMetaProps = {
|
type ProformaDetailHeaderMetaProps = {
|
||||||
proforma: Proforma;
|
customerName: string;
|
||||||
|
documentReference: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProformaDetailHeaderMeta = ({ proforma }: ProformaDetailHeaderMetaProps) => {
|
export const ProformaDetailHeaderMeta = ({
|
||||||
|
customerName,
|
||||||
|
documentReference,
|
||||||
|
}: ProformaDetailHeaderMetaProps) => {
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => [
|
() => [customerName, documentReference],
|
||||||
proforma.recipient.name ?? "Cliente sin nombre",
|
[customerName, documentReference]
|
||||||
formatProformaDate(proforma.proformaDate),
|
|
||||||
`Total ${formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode)}`,
|
|
||||||
],
|
|
||||||
[proforma]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-base text-foreground font-semibold">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm font-medium text-muted-foreground">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div className="flex items-center gap-3" key={`${item}-${index}`}>
|
<div className="flex items-center gap-3" key={`${item}-${index}`}>
|
||||||
{index > 0 ? <span className="hidden text-border sm:inline">|</span> : null}
|
{index > 0 ? <span className="hidden text-border sm:inline">|</span> : null}
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
|
||||||
import { SquarePenIcon } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
type ProformaDetailInspectorCardProps = {
|
|
||||||
title: string;
|
|
||||||
onEdit?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailInspectorCard = ({
|
|
||||||
title,
|
|
||||||
onEdit,
|
|
||||||
children,
|
|
||||||
}: ProformaDetailInspectorCardProps) => {
|
|
||||||
return (
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
|
||||||
<CardTitle className="text-xl">{title}</CardTitle>
|
|
||||||
{onEdit ? (
|
|
||||||
<Button onClick={onEdit} size="icon-sm" type="button" variant="ghost">
|
|
||||||
<SquarePenIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>{children}</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { Separator } from "@repo/shadcn-ui/components";
|
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
|
||||||
|
|
||||||
type ProformaDetailNotesInspectorCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
onEdit: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailNotesInspectorCard = ({
|
|
||||||
proforma,
|
|
||||||
onEdit,
|
|
||||||
}: ProformaDetailNotesInspectorCardProps) => {
|
|
||||||
return (
|
|
||||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Notas">
|
|
||||||
<div className="space-y-4 text-sm leading-6 text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 font-medium text-foreground">Descripción</p>
|
|
||||||
<p>{proforma.description ?? "Sin descripción."}</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 font-medium text-foreground">Observaciones</p>
|
|
||||||
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ProformaDetailInspectorCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { Separator } from "@repo/shadcn-ui/components";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useId } from "react";
|
||||||
|
|
||||||
|
export interface ProformaDetailSectionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformaDetailSection = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: ProformaDetailSectionProps) => {
|
||||||
|
const headingId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-foreground" id={headingId}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-sm leading-5 text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{action ? <div className="shrink-0">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
|
<Separator />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,30 +1,44 @@
|
|||||||
import { Card, CardContent, Skeleton } from "@repo/shadcn-ui/components";
|
import { Skeleton } from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
export const ProformaDetailSkeleton = () => {
|
export const ProformaDetailSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-muted/30">
|
||||||
<Card className="shadow-sm">
|
<div className="shrink-0 border-b bg-background px-4 py-3 sm:px-6">
|
||||||
<CardContent className="space-y-4 p-6">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<Skeleton className="h-5 w-40" />
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Skeleton className="h-10 w-96" />
|
<Skeleton className="size-10 rounded-md" />
|
||||||
<Skeleton className="h-5 w-80" />
|
<div className="space-y-2">
|
||||||
</CardContent>
|
<Skeleton className="h-8 w-64" />
|
||||||
</Card>
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.8fr)_360px]">
|
<div className="flex items-center gap-2">
|
||||||
<Card className="shadow-sm">
|
<Skeleton className="h-10 w-28" />
|
||||||
<CardContent className="space-y-4 p-6">
|
<Skeleton className="h-10 w-28" />
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="size-10 rounded-md" />
|
||||||
<Skeleton className="h-[840px] w-full" />
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<Skeleton className="h-28 w-full rounded-xl" />
|
<Skeleton className="h-7 w-24 rounded-full" />
|
||||||
<Skeleton className="h-40 w-full rounded-xl" />
|
<Skeleton className="h-7 w-28 rounded-full" />
|
||||||
<Skeleton className="h-44 w-full rounded-xl" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
</div>
|
||||||
<Skeleton className="h-36 w-full rounded-xl" />
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
|
<div className="border-b bg-muted/30 p-4 sm:p-6 lg:min-h-0 lg:border-r lg:border-b-0">
|
||||||
|
<Skeleton className="h-full min-h-[32rem] w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background p-4 sm:p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-28 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-[34rem] w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@repo/shadcn-ui/components";
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import { CheckCircle2Icon } from "lucide-react";
|
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
const getStatusTone = (status: Proforma["status"]) => {
|
|
||||||
switch (status) {
|
|
||||||
case "approved":
|
|
||||||
return "border-emerald-200 bg-emerald-50 text-emerald-900";
|
|
||||||
case "sent":
|
|
||||||
return "border-amber-200 bg-amber-50 text-amber-900";
|
|
||||||
case "issued":
|
|
||||||
return "border-sky-200 bg-sky-50 text-sky-900";
|
|
||||||
case "rejected":
|
|
||||||
return "border-red-200 bg-red-50 text-red-900";
|
|
||||||
default:
|
|
||||||
return "border-slate-200 bg-slate-50 text-slate-900";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusSummary = (proforma: Proforma) => {
|
|
||||||
if (proforma.isArchived) {
|
|
||||||
return {
|
|
||||||
title: "Proforma archivada",
|
|
||||||
description: "Está fuera del flujo principal y puedes recuperarla cuando lo necesites.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (proforma.status) {
|
|
||||||
case "approved":
|
|
||||||
return {
|
|
||||||
title: "Lista para convertirse en factura",
|
|
||||||
description: "La proforma ya está aprobada por el cliente y puede emitirse como factura.",
|
|
||||||
};
|
|
||||||
case "sent":
|
|
||||||
return {
|
|
||||||
title: "Pendiente de respuesta del cliente",
|
|
||||||
description:
|
|
||||||
"Se ha enviado al cliente y está a la espera de aceptación o rechazo por parte del cliente.",
|
|
||||||
};
|
|
||||||
case "issued":
|
|
||||||
return {
|
|
||||||
title: "Convertida en factura",
|
|
||||||
description: "La proforma ya ha sido emitida y forma parte del flujo fiscal.",
|
|
||||||
};
|
|
||||||
case "rejected":
|
|
||||||
return {
|
|
||||||
title: "Requiere revisión",
|
|
||||||
description: "El cliente la ha rechazado y conviene revisar condiciones o importes.",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
title: "Pendiente de revisión",
|
|
||||||
description: "Sigue en borrador y todavía puede modificarse antes de enviarla al cliente.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProformaDetailStatusInspectorCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailStatusInspectorCard = ({
|
|
||||||
proforma,
|
|
||||||
}: ProformaDetailStatusInspectorCardProps) => {
|
|
||||||
const summary = getStatusSummary(proforma);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert className={cn("shadow-sm", getStatusTone(proforma.status))}>
|
|
||||||
<CheckCircle2Icon className="size-4" />
|
|
||||||
<AlertTitle>{summary.title}</AlertTitle>
|
|
||||||
<AlertDescription>{summary.description}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
|
||||||
|
|
||||||
import type { Proforma } from "../../../shared";
|
|
||||||
|
|
||||||
import { formatProformaMoney } from "./proforma-detail-formatters";
|
|
||||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
|
||||||
|
|
||||||
type ProformaDetailTotalsInspectorCardProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaDetailTotalsInspectorCard = ({
|
|
||||||
proforma,
|
|
||||||
}: ProformaDetailTotalsInspectorCardProps) => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
label: "Subtotal",
|
|
||||||
value: formatProformaMoney(
|
|
||||||
proforma.subtotalAmount,
|
|
||||||
proforma.currencyCode,
|
|
||||||
proforma.languageCode
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Descuentos",
|
|
||||||
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
|
|
||||||
valueClassName: "text-red-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "IVA",
|
|
||||||
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Total",
|
|
||||||
value: formatProformaMoney(
|
|
||||||
proforma.totalAmount,
|
|
||||||
proforma.currencyCode,
|
|
||||||
proforma.languageCode
|
|
||||||
),
|
|
||||||
valueClassName: "text-lg font-semibold text-foreground",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProformaDetailInspectorCard title="Resumen económico">
|
|
||||||
<FieldValueList itemClassName="items-center" items={items} valueClassName="tabular-nums" />
|
|
||||||
</ProformaDetailInspectorCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./blocks";
|
export * from "./blocks";
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
export * from "./pages";
|
export * from "./pages";
|
||||||
|
export * from "./sections";
|
||||||
|
|||||||
@ -1,121 +1,102 @@
|
|||||||
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
|
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
|
||||||
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
import { IssueProformaDialog } from "../../../issue-proforma";
|
import { ChangeProformaStatusDialog } from "../../../change-status";
|
||||||
import { ProformaStatusBadge } from "../../../list/ui/components";
|
import { IssueProformaDialog } from "../../../issue-proforma";
|
||||||
import { ProformaPageHeader } from "../../../shared";
|
import { useProformaDetailPageController } from "../../controllers";
|
||||||
import { useProformaDetailPageController } from "../../controllers";
|
import {
|
||||||
import {
|
ProformaDetailContent,
|
||||||
ProformaDetailHeaderActions,
|
ProformaDetailHeader,
|
||||||
ProformaDetailPreviewCard,
|
ProformaDetailInspector,
|
||||||
ProformaDetailSidebar,
|
ProformaDocumentPane,
|
||||||
} from "../blocks";
|
} from "../blocks";
|
||||||
import {
|
import { ProformaDetailSkeleton } from "../components";
|
||||||
ProformaDetailArchivedBadge,
|
|
||||||
ProformaDetailHeaderMeta,
|
export const ProformaDetailPage = () => {
|
||||||
ProformaDetailSkeleton,
|
const { t } = useTranslation();
|
||||||
} from "../components";
|
const ctrl = useProformaDetailPageController();
|
||||||
|
|
||||||
export const ProformaDetailPage = () => {
|
if (ctrl.state.isInitialLoading) {
|
||||||
const ctrl = useProformaDetailPageController();
|
return <ProformaDetailSkeleton />;
|
||||||
|
}
|
||||||
if (ctrl.proformaQuery.isLoading) {
|
|
||||||
return <ProformaDetailSkeleton />;
|
if (ctrl.state.isError) {
|
||||||
}
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
if (ctrl.proformaQuery.isError) {
|
<div className="mx-auto w-full max-w-3xl rounded-lg border bg-background p-6">
|
||||||
return (
|
<ErrorAlert
|
||||||
<AppContent>
|
message={ctrl.state.errorMessage ?? t("pages.proformas.update.load_error.message")}
|
||||||
<ErrorAlert
|
title={t("pages.proformas.update.load_error.title")}
|
||||||
message={
|
/>
|
||||||
ctrl.proformaQuery.error instanceof Error
|
</div>
|
||||||
? ctrl.proformaQuery.error.message
|
</div>
|
||||||
: "No se pudo cargar la proforma."
|
);
|
||||||
}
|
}
|
||||||
title="Error al cargar la proforma"
|
|
||||||
/>
|
if (ctrl.state.isNotFound || !(ctrl.header && ctrl.general && ctrl.document)) {
|
||||||
|
return (
|
||||||
<div className="flex items-center justify-end">
|
<div className="p-4 sm:p-6">
|
||||||
<BackHistoryButton onClick={() => ctrl.navigateBack()} />
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
</div>
|
<NotFoundCard
|
||||||
</AppContent>
|
message={t("pages.proformas.update.not_found_msg")}
|
||||||
);
|
title={t("pages.proformas.update.not_found_title")}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
if (!ctrl.proforma) {
|
</div>
|
||||||
return (
|
);
|
||||||
<AppContent>
|
}
|
||||||
<NotFoundCard
|
|
||||||
message="Revisa el identificador o vuelve al listado de proformas."
|
return (
|
||||||
title="Proforma no encontrada"
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
/>
|
<ProformaDetailHeader
|
||||||
</AppContent>
|
actions={ctrl.actions}
|
||||||
);
|
header={ctrl.header}
|
||||||
}
|
onBack={ctrl.navigation.navigateBack}
|
||||||
|
onExecuteAction={ctrl.handlers.executeAction}
|
||||||
return (
|
/>
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<ProformaPageHeader
|
<ProformaDetailContent
|
||||||
actions={
|
documentPane={
|
||||||
<ProformaDetailHeaderActions
|
<ProformaDocumentPane
|
||||||
isArchived={ctrl.proforma.isArchived}
|
document={ctrl.document}
|
||||||
isPrimaryLoading={ctrl.issueDialogCtrl.isSubmitting}
|
onDownload={ctrl.viewer.handleDownload}
|
||||||
onArchiveToggle={ctrl.handleArchiveToggle}
|
onOpen={ctrl.viewer.handleOpenPreviewPdf}
|
||||||
onDownload={ctrl.handleDownload}
|
onPrint={ctrl.viewer.handlePrintPreview}
|
||||||
onDuplicate={ctrl.handleDuplicate}
|
onRetry={ctrl.viewer.handleRetryDocument}
|
||||||
primaryAction={ctrl.primaryAction}
|
state={ctrl.documentState}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backLabel="Volver al listado"
|
inspector={
|
||||||
onBack={ctrl.navigateBack}
|
<ProformaDetailInspector
|
||||||
title={<>{`Proforma ${ctrl.proforma.proformaReference}`}</>}
|
general={ctrl.general}
|
||||||
>
|
onOpenCustomer={ctrl.navigation.openCustomer}
|
||||||
<div className="border-b bg-background px-4 py-4 sm:px-6">
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
}
|
||||||
<ProformaStatusBadge className="text-sm" status={ctrl.proforma.status} />
|
/>
|
||||||
{ctrl.proforma.isArchived ? <ProformaDetailArchivedBadge /> : null}
|
|
||||||
<ProformaDetailHeaderMeta proforma={ctrl.proforma} />
|
<IssueProformaDialog
|
||||||
</div>
|
isSubmitting={ctrl.dialogs.issue.isSubmitting}
|
||||||
</div>
|
onConfirm={ctrl.dialogs.issue.confirmIssue}
|
||||||
</ProformaPageHeader>
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
<div className="flex-1 overflow-auto p-6">
|
ctrl.dialogs.issue.closeDialog();
|
||||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,2.2fr)_320px] 2xl:grid-cols-[minmax(0,2.5fr)_340px]">
|
}
|
||||||
<ProformaDetailPreviewCard
|
}}
|
||||||
error={
|
open={ctrl.dialogs.issue.open}
|
||||||
ctrl.previewPdfQuery.isError
|
target={ctrl.dialogs.issue.target}
|
||||||
? ctrl.previewPdfQuery.error instanceof Error
|
/>
|
||||||
? ctrl.previewPdfQuery.error.message
|
|
||||||
: "No se pudo cargar el PDF."
|
<ChangeProformaStatusDialog
|
||||||
: null
|
isSubmitting={ctrl.dialogs.changeStatus.isSubmitting}
|
||||||
}
|
onConfirm={ctrl.dialogs.changeStatus.confirmChangeStatus}
|
||||||
isLoading={ctrl.previewPdfQuery.isLoading || ctrl.previewPdfQuery.isFetching}
|
onOpenChange={(open) => {
|
||||||
onDownload={ctrl.handleDownload}
|
if (!open) {
|
||||||
onOpen={ctrl.handleOpenPreviewPdf}
|
ctrl.dialogs.changeStatus.closeDialog();
|
||||||
onPrint={ctrl.handlePrintPreview}
|
}
|
||||||
src={ctrl.previewPdfUrl}
|
}}
|
||||||
/>
|
open={ctrl.dialogs.changeStatus.open}
|
||||||
|
targets={ctrl.dialogs.changeStatus.targets}
|
||||||
<ProformaDetailSidebar
|
/>
|
||||||
onEdit={ctrl.handleEdit}
|
</div>
|
||||||
paymentMethodLabel={ctrl.paymentMethodLabel}
|
);
|
||||||
paymentTermLabel={ctrl.paymentTermLabel}
|
};
|
||||||
proforma={ctrl.proforma}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IssueProformaDialog
|
|
||||||
isSubmitting={ctrl.issueDialogCtrl.isSubmitting}
|
|
||||||
onConfirm={ctrl.issueDialogCtrl.confirmIssue}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
ctrl.issueDialogCtrl.closeDialog();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={ctrl.issueDialogCtrl.open}
|
|
||||||
target={ctrl.issueDialogCtrl.target}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
export * from "./proforma-commercial-section";
|
||||||
|
export * from "./proforma-content-section";
|
||||||
|
export * from "./proforma-customer-section";
|
||||||
|
export * from "./proforma-dates-section";
|
||||||
|
export * from "./proforma-fiscal-section";
|
||||||
|
export * from "./proforma-linked-invoice-section";
|
||||||
|
export * from "./proforma-summary-section";
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailCommercialViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaCommercialSectionProps = {
|
||||||
|
commercial: ProformaDetailCommercialViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaCommercialSection = ({
|
||||||
|
commercial,
|
||||||
|
}: ProformaCommercialSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.commercial")}
|
||||||
|
title={t("proformas.detail.sections.commercial")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.payment_method")}
|
||||||
|
value={commercial.paymentMethodLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.payment_term")}
|
||||||
|
value={commercial.paymentTermLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.internal_reference")}
|
||||||
|
value={commercial.internalReferenceLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailContentViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaContentSectionProps = {
|
||||||
|
content: ProformaDetailContentViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaContentSection = ({
|
||||||
|
content,
|
||||||
|
}: ProformaContentSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!content.description && !content.notes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.content")}
|
||||||
|
title={t("proformas.detail.sections.content")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{content.description ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{t("proformas.detail.fields.description")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||||
|
{content.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{content.notes ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{t("proformas.detail.fields.notes")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||||
|
{content.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailCustomerViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaCustomerSectionProps = {
|
||||||
|
customer: ProformaDetailCustomerViewModel;
|
||||||
|
onOpenCustomer: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaCustomerSection = ({
|
||||||
|
customer,
|
||||||
|
onOpenCustomer,
|
||||||
|
}: ProformaCustomerSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
disabled={!customer.canOpenCustomer}
|
||||||
|
onClick={onOpenCustomer}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon aria-hidden="true" className="mr-2 size-4" />
|
||||||
|
{t("proformas.detail.actions.open_customer")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
description={t("proformas.detail.section_descriptions.customer")}
|
||||||
|
title={t("proformas.detail.sections.customer")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProformaDetailField label={t("form_groups.proformas.customer.title")} value={customer.name} />
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.customer_tin")}
|
||||||
|
value={customer.tin ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.address")}
|
||||||
|
value={
|
||||||
|
customer.addressLines.length > 0 ? (
|
||||||
|
<div className="space-y-1 whitespace-pre-wrap">
|
||||||
|
{customer.addressLines.map((line) => (
|
||||||
|
<p key={line}>{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("proformas.detail.empty.no_address")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
valueClassName="sm:text-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailDatesViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaDatesSectionProps = {
|
||||||
|
dates: ProformaDetailDatesViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaDatesSection = ({ dates }: ProformaDatesSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.dates")}
|
||||||
|
title={t("proformas.detail.sections.dates")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.proforma_date")}
|
||||||
|
value={dates.proformaDateLabel}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.operation_date")}
|
||||||
|
value={dates.operationDateLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailFiscalViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaFiscalSectionProps = {
|
||||||
|
fiscal: ProformaDetailFiscalViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaFiscalSection = ({ fiscal }: ProformaFiscalSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.fiscal")}
|
||||||
|
title={t("proformas.detail.sections.fiscal")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.tax_regime")}
|
||||||
|
value={fiscal.taxRegimeLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.tax_mode")}
|
||||||
|
value={fiscal.taxModeLabel}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.subtotal")}
|
||||||
|
value={fiscal.subtotalLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.discount")}
|
||||||
|
value={fiscal.discountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.taxable_base")}
|
||||||
|
value={fiscal.taxableAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.tax")}
|
||||||
|
value={fiscal.taxAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
{fiscal.usesRec ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.rec")}
|
||||||
|
value={fiscal.recAmountLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{fiscal.usesRetention ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.retention")}
|
||||||
|
value={fiscal.retentionAmountLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.total")}
|
||||||
|
value={fiscal.totalAmountLabel}
|
||||||
|
valueClassName="tabular-nums text-base font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fiscal.breakdown.length > 0 ? (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-muted/10 p-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{t("proformas.detail.fields.tax_breakdown")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fiscal.breakdown.map((item) => (
|
||||||
|
<div className="space-y-2 border-b pb-4 last:border-b-0 last:pb-0" key={item.key}>
|
||||||
|
<p className="text-sm font-medium text-foreground">{item.taxLabel}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.taxable_base")}
|
||||||
|
value={item.taxableAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.tax")}
|
||||||
|
value={item.taxAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
{item.recLabel && item.recAmountLabel ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={item.recLabel}
|
||||||
|
value={item.recAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{item.retentionLabel && item.retentionAmountLabel ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={item.retentionLabel}
|
||||||
|
value={item.retentionAmountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { Badge } from "@repo/shadcn-ui/components";
|
||||||
|
import { CircleAlertIcon, FileClockIcon, FileOutputIcon, Link2OffIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaDetailLinkedInvoiceViewModel } from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaLinkedInvoiceSectionProps = {
|
||||||
|
linkedInvoice: ProformaDetailLinkedInvoiceViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_ICON = {
|
||||||
|
"not-issued": FileClockIcon,
|
||||||
|
"ready-to-issue": FileOutputIcon,
|
||||||
|
"issued-without-link": CircleAlertIcon,
|
||||||
|
"issued-linked-without-detail-route": Link2OffIcon,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ProformaLinkedInvoiceSection = ({
|
||||||
|
linkedInvoice,
|
||||||
|
}: ProformaLinkedInvoiceSectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = STATE_ICON[linkedInvoice.state];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.linked_invoice")}
|
||||||
|
title={t("proformas.detail.sections.linked_invoice")}
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border bg-muted/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-0.5 text-muted-foreground">
|
||||||
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Badge variant="outline">{linkedInvoice.primaryText}</Badge>
|
||||||
|
{linkedInvoice.secondaryText ? (
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">{linkedInvoice.secondaryText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkedInvoice.state === "issued-linked-without-detail-route" && linkedInvoice.linkedInvoiceId ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.linked_invoice_id")}
|
||||||
|
value={linkedInvoice.linkedInvoiceId}
|
||||||
|
valueClassName="break-all sm:text-left"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type {
|
||||||
|
ProformaDetailContentViewModel,
|
||||||
|
ProformaDetailDatesViewModel,
|
||||||
|
ProformaDetailSummaryViewModel,
|
||||||
|
} from "../../entities";
|
||||||
|
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||||
|
|
||||||
|
type ProformaSummarySectionProps = {
|
||||||
|
summary: ProformaDetailSummaryViewModel;
|
||||||
|
dates: ProformaDetailDatesViewModel;
|
||||||
|
content: ProformaDetailContentViewModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProformaSummarySection = ({
|
||||||
|
summary,
|
||||||
|
dates,
|
||||||
|
content,
|
||||||
|
}: ProformaSummarySectionProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProformaDetailSection
|
||||||
|
description={t("proformas.detail.section_descriptions.summary")}
|
||||||
|
title={t("proformas.detail.sections.summary")}
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-4 py-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{t("proformas.detail.fields.total")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||||
|
{summary.totalLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.reference")}
|
||||||
|
value={summary.documentReference}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.status")}
|
||||||
|
value={summary.statusLabel}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.proforma_date")}
|
||||||
|
value={dates.proformaDateLabel}
|
||||||
|
/>
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.operation_date")}
|
||||||
|
value={dates.operationDateLabel ?? t("proformas.detail.empty.not_available")}
|
||||||
|
/>
|
||||||
|
{content.description ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.description")}
|
||||||
|
value={content.description}
|
||||||
|
valueClassName="whitespace-pre-wrap sm:text-left"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{content.notes ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.notes")}
|
||||||
|
value={content.notes}
|
||||||
|
valueClassName="whitespace-pre-wrap sm:text-left"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{summary.globalDiscountLabel ? (
|
||||||
|
<ProformaDetailField
|
||||||
|
label={t("proformas.detail.fields.global_discount")}
|
||||||
|
value={summary.globalDiscountLabel}
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ProformaDetailSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
import type { ProformaStatus } from "../../shared";
|
||||||
|
import { PROFORMA_STATUS_TRANSITIONS } from "../../shared";
|
||||||
|
import type {
|
||||||
|
ProformaDetailActionKey,
|
||||||
|
ProformaDetailActionViewModel,
|
||||||
|
ProformaDetailActionsViewModel,
|
||||||
|
} from "../entities";
|
||||||
|
|
||||||
|
export interface ProformaDetailActionLabels {
|
||||||
|
edit: string;
|
||||||
|
changeStatus: string;
|
||||||
|
issue: string;
|
||||||
|
duplicate: string;
|
||||||
|
downloadPdf: string;
|
||||||
|
archive: string;
|
||||||
|
unarchive: string;
|
||||||
|
disabledReasons: {
|
||||||
|
archivedOnly: string;
|
||||||
|
archiveConflict: string;
|
||||||
|
editIssued: string;
|
||||||
|
editArchived: string;
|
||||||
|
statusIssued: string;
|
||||||
|
statusArchived: string;
|
||||||
|
issueNotAllowed: string;
|
||||||
|
operationInProgress: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildProformaDetailActionsParams {
|
||||||
|
status: ProformaStatus;
|
||||||
|
isArchived: boolean;
|
||||||
|
labels: ProformaDetailActionLabels;
|
||||||
|
loading: Partial<Record<ProformaDetailActionKey, boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIDDEN_ACTION: Omit<ProformaDetailActionViewModel, "key" | "label" | "placement"> = {
|
||||||
|
visibility: "hidden",
|
||||||
|
enabled: false,
|
||||||
|
loading: false,
|
||||||
|
disabledReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasManualTransition = (status: ProformaStatus) => {
|
||||||
|
return (PROFORMA_STATUS_TRANSITIONS[status] ?? []).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAction = (
|
||||||
|
key: ProformaDetailActionKey,
|
||||||
|
label: string,
|
||||||
|
placement: ProformaDetailActionViewModel["placement"],
|
||||||
|
visibility: ProformaDetailActionViewModel["visibility"],
|
||||||
|
enabled: boolean,
|
||||||
|
loading: boolean,
|
||||||
|
disabledReason: string | null
|
||||||
|
): ProformaDetailActionViewModel => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
placement,
|
||||||
|
visibility,
|
||||||
|
enabled,
|
||||||
|
loading,
|
||||||
|
disabledReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildProformaDetailActions = ({
|
||||||
|
status,
|
||||||
|
isArchived,
|
||||||
|
labels,
|
||||||
|
loading,
|
||||||
|
}: BuildProformaDetailActionsParams): ProformaDetailActionsViewModel => {
|
||||||
|
const isDraft = status === "draft";
|
||||||
|
const isSent = status === "sent";
|
||||||
|
const isApproved = status === "approved";
|
||||||
|
const isRejected = status === "rejected";
|
||||||
|
const isIssued = status === "issued";
|
||||||
|
const canChangeStatus = hasManualTransition(status);
|
||||||
|
|
||||||
|
const items: ProformaDetailActionViewModel[] = [
|
||||||
|
buildAction(
|
||||||
|
"edit",
|
||||||
|
labels.edit,
|
||||||
|
isApproved ? "secondary" : "primary",
|
||||||
|
"visible",
|
||||||
|
!(isArchived || isIssued),
|
||||||
|
loading.edit ?? false,
|
||||||
|
isIssued
|
||||||
|
? labels.disabledReasons.editIssued
|
||||||
|
: isArchived
|
||||||
|
? labels.disabledReasons.editArchived
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"change-status",
|
||||||
|
labels.changeStatus,
|
||||||
|
"menu",
|
||||||
|
isIssued ? "hidden" : "visible",
|
||||||
|
!isArchived && canChangeStatus,
|
||||||
|
loading["change-status"] ?? false,
|
||||||
|
isArchived
|
||||||
|
? labels.disabledReasons.statusArchived
|
||||||
|
: canChangeStatus
|
||||||
|
? null
|
||||||
|
: labels.disabledReasons.issueNotAllowed
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"issue",
|
||||||
|
labels.issue,
|
||||||
|
"primary",
|
||||||
|
isApproved || isSent ? "visible" : "hidden",
|
||||||
|
isApproved && !isArchived,
|
||||||
|
loading.issue ?? false,
|
||||||
|
isApproved ? null : labels.disabledReasons.issueNotAllowed
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"duplicate",
|
||||||
|
labels.duplicate,
|
||||||
|
"menu",
|
||||||
|
"visible",
|
||||||
|
true,
|
||||||
|
loading.duplicate ?? false,
|
||||||
|
null
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"download-pdf",
|
||||||
|
labels.downloadPdf,
|
||||||
|
isApproved ? "secondary" : "secondary",
|
||||||
|
"visible",
|
||||||
|
!(loading["download-pdf"] ?? false),
|
||||||
|
loading["download-pdf"] ?? false,
|
||||||
|
loading["download-pdf"] ? labels.disabledReasons.operationInProgress : null
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"archive",
|
||||||
|
labels.archive,
|
||||||
|
"menu",
|
||||||
|
isArchived || !(isDraft || isRejected) ? "hidden" : "visible",
|
||||||
|
isDraft || isRejected,
|
||||||
|
loading.archive ?? false,
|
||||||
|
isDraft || isRejected ? null : labels.disabledReasons.archiveConflict
|
||||||
|
),
|
||||||
|
buildAction(
|
||||||
|
"unarchive",
|
||||||
|
labels.unarchive,
|
||||||
|
"menu",
|
||||||
|
isArchived ? "visible" : "hidden",
|
||||||
|
isArchived,
|
||||||
|
loading.unarchive ?? false,
|
||||||
|
isArchived ? null : labels.disabledReasons.archivedOnly
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizedItems = items.map((item) => {
|
||||||
|
if (item.visibility === "hidden") {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...HIDDEN_ACTION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: normalizedItems,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
import { DateHelper, MoneyHelper, NumberHelper } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { Proforma } from "../../shared";
|
||||||
|
import type {
|
||||||
|
ProformaDetailCommercialViewModel,
|
||||||
|
ProformaDetailContentViewModel,
|
||||||
|
ProformaDetailCustomerViewModel,
|
||||||
|
ProformaDetailDatesViewModel,
|
||||||
|
ProformaDetailDocumentViewModel,
|
||||||
|
ProformaDetailFiscalBreakdownItemViewModel,
|
||||||
|
ProformaDetailFiscalViewModel,
|
||||||
|
ProformaDetailGeneralViewModel,
|
||||||
|
ProformaDetailHeaderViewModel,
|
||||||
|
ProformaDetailLinkedInvoiceState,
|
||||||
|
ProformaDetailLinkedInvoiceViewModel,
|
||||||
|
ProformaDetailSummaryViewModel,
|
||||||
|
} from "../entities";
|
||||||
|
|
||||||
|
export interface ProformaDetailTranslate {
|
||||||
|
(key: string, params?: Record<string, string | number | null | undefined>): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildProformaDetailViewModelsContext {
|
||||||
|
translate: ProformaDetailTranslate;
|
||||||
|
paymentMethodLabel: string | null;
|
||||||
|
paymentTermLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (proforma: Proforma, value: number) => {
|
||||||
|
return MoneyHelper.formatCurrency(
|
||||||
|
value,
|
||||||
|
2,
|
||||||
|
proforma.currencyCode,
|
||||||
|
proforma.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 = (proforma: Proforma): string[] => {
|
||||||
|
const { recipient } = proforma;
|
||||||
|
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 = (proforma: Proforma): string | null => {
|
||||||
|
const valuedItems = proforma.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: Proforma["taxes"][number],
|
||||||
|
translate: ProformaDetailTranslate
|
||||||
|
): string => {
|
||||||
|
const percentage =
|
||||||
|
item.ivaPercentage !== null ? `${NumberHelper.formatNumber(item.ivaPercentage, 2)}%` : null;
|
||||||
|
const code = item.ivaCode ?? translate("proformas.detail.fiscal.unknown_tax_code");
|
||||||
|
|
||||||
|
return percentage ? `${code} · ${percentage}` : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLinkedInvoiceState = (proforma: Proforma): ProformaDetailLinkedInvoiceState => {
|
||||||
|
if (proforma.status === "approved") {
|
||||||
|
return "ready-to-issue";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proforma.status !== "issued") {
|
||||||
|
return "not-issued";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proforma.linkedInvoiceId) {
|
||||||
|
return "issued-without-link";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "issued-linked-without-detail-route";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProformaDetailHeaderViewModel = (
|
||||||
|
proforma: Proforma,
|
||||||
|
context: BuildProformaDetailViewModelsContext
|
||||||
|
): ProformaDetailHeaderViewModel => {
|
||||||
|
return {
|
||||||
|
title: context.translate("proformas.detail.header.title", {
|
||||||
|
reference: proforma.proformaReference,
|
||||||
|
}),
|
||||||
|
customerName: proforma.recipient.name || context.translate("proformas.detail.customer.unknown"),
|
||||||
|
documentReference: proforma.proformaReference,
|
||||||
|
status: proforma.status,
|
||||||
|
statusLabel: context.translate(`catalog.proformas.status.${proforma.status}.label`),
|
||||||
|
isArchived: proforma.isArchived,
|
||||||
|
archivedLabel: proforma.isArchived
|
||||||
|
? context.translate("proformas.detail.header.archived_label")
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProformaDetailDocumentViewModel = (
|
||||||
|
src: string | null,
|
||||||
|
context: BuildProformaDetailViewModelsContext
|
||||||
|
): ProformaDetailDocumentViewModel => {
|
||||||
|
return {
|
||||||
|
title: context.translate("proformas.detail.document.title"),
|
||||||
|
description: context.translate("proformas.detail.document.description"),
|
||||||
|
src,
|
||||||
|
mode: "pdf",
|
||||||
|
capabilities: {
|
||||||
|
canOpen: Boolean(src),
|
||||||
|
canDownload: Boolean(src),
|
||||||
|
canPrint: Boolean(src),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProformaDetailGeneralViewModel = (
|
||||||
|
proforma: Proforma,
|
||||||
|
context: BuildProformaDetailViewModelsContext
|
||||||
|
): ProformaDetailGeneralViewModel => {
|
||||||
|
const linkedInvoiceState = buildLinkedInvoiceState(proforma);
|
||||||
|
const usesRec = proforma.recAmount > 0 || proforma.taxConfig.usesEquivalenceSurcharge;
|
||||||
|
const usesRetention = proforma.retentionAmount > 0 || proforma.taxConfig.usesRetention;
|
||||||
|
|
||||||
|
const breakdown: ProformaDetailFiscalBreakdownItemViewModel[] = proforma.taxes.map((tax, index) => ({
|
||||||
|
key: `${tax.ivaCode ?? "tax"}-${index}`,
|
||||||
|
taxLabel: buildTaxLabel(tax, context.translate),
|
||||||
|
taxableAmountLabel: formatMoney(proforma, tax.taxableAmount),
|
||||||
|
taxAmountLabel: formatMoney(proforma, tax.ivaAmount),
|
||||||
|
recLabel:
|
||||||
|
tax.recPercentage !== null
|
||||||
|
? `${tax.recCode ?? context.translate("proformas.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.recPercentage, 2)}%`
|
||||||
|
: null,
|
||||||
|
recAmountLabel: tax.recPercentage !== null ? formatMoney(proforma, tax.recAmount) : null,
|
||||||
|
retentionLabel:
|
||||||
|
tax.retentionPercentage !== null
|
||||||
|
? `${tax.retentionCode ?? context.translate("proformas.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.retentionPercentage, 2)}%`
|
||||||
|
: null,
|
||||||
|
retentionAmountLabel:
|
||||||
|
tax.retentionPercentage !== null ? formatMoney(proforma, tax.retentionAmount) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const linkedInvoice: ProformaDetailLinkedInvoiceViewModel = {
|
||||||
|
state: linkedInvoiceState,
|
||||||
|
linkedInvoiceId: proforma.linkedInvoiceId ?? null,
|
||||||
|
primaryText: context.translate(`proformas.detail.linked_invoice.states.${linkedInvoiceState}.title`),
|
||||||
|
secondaryText: context.translate(
|
||||||
|
`proformas.detail.linked_invoice.states.${linkedInvoiceState}.description`
|
||||||
|
),
|
||||||
|
canOpenLinkedInvoice: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary: ProformaDetailSummaryViewModel = {
|
||||||
|
totalLabel: formatMoney(proforma, proforma.totalAmount),
|
||||||
|
documentReference: proforma.proformaReference,
|
||||||
|
statusLabel: context.translate(`catalog.proformas.status.${proforma.status}.label`),
|
||||||
|
currencyCode: proforma.currencyCode,
|
||||||
|
lineCount: proforma.items.length,
|
||||||
|
totalUnitsLabel: buildTotalUnitsLabel(proforma),
|
||||||
|
globalDiscountLabel:
|
||||||
|
proforma.globalDiscountPercentage > 0
|
||||||
|
? `${NumberHelper.formatNumber(proforma.globalDiscountPercentage, 2)}%`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const customer: ProformaDetailCustomerViewModel = {
|
||||||
|
customerId: proforma.customerId,
|
||||||
|
name: proforma.recipient.name || context.translate("proformas.detail.customer.unknown"),
|
||||||
|
tin: proforma.recipient.tin,
|
||||||
|
addressLines: buildAddressLines(proforma),
|
||||||
|
canOpenCustomer: Boolean(proforma.customerId),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dates: ProformaDetailDatesViewModel = {
|
||||||
|
proformaDateLabel: DateHelper.format(proforma.proformaDate),
|
||||||
|
operationDateLabel: formatDate(proforma.operationDate),
|
||||||
|
};
|
||||||
|
|
||||||
|
const commercial: ProformaDetailCommercialViewModel = {
|
||||||
|
paymentMethodLabel: context.paymentMethodLabel,
|
||||||
|
paymentTermLabel: context.paymentTermLabel,
|
||||||
|
languageLabel: buildLanguageLabel(proforma.languageCode),
|
||||||
|
internalReferenceLabel: proforma.reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fiscal: ProformaDetailFiscalViewModel = {
|
||||||
|
taxRegimeLabel: proforma.taxRegimeCode,
|
||||||
|
taxModeLabel: context.translate(`proformas.detail.fiscal.tax_mode.${proforma.taxConfig.taxMode}`),
|
||||||
|
subtotalLabel: formatMoney(proforma, proforma.subtotalAmount),
|
||||||
|
discountLabel: formatMoney(proforma, proforma.totalDiscountAmount),
|
||||||
|
taxableAmountLabel: formatMoney(proforma, proforma.taxableAmount),
|
||||||
|
taxAmountLabel: formatMoney(proforma, proforma.ivaAmount),
|
||||||
|
recAmountLabel: usesRec ? formatMoney(proforma, proforma.recAmount) : null,
|
||||||
|
retentionAmountLabel: usesRetention ? formatMoney(proforma, proforma.retentionAmount) : null,
|
||||||
|
totalAmountLabel: formatMoney(proforma, proforma.totalAmount),
|
||||||
|
breakdown,
|
||||||
|
usesRec,
|
||||||
|
usesRetention,
|
||||||
|
};
|
||||||
|
|
||||||
|
const content: ProformaDetailContentViewModel = {
|
||||||
|
description: proforma.description,
|
||||||
|
notes: proforma.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
customer,
|
||||||
|
dates,
|
||||||
|
commercial,
|
||||||
|
fiscal,
|
||||||
|
content,
|
||||||
|
linkedInvoice,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./build-proforma-detail-actions";
|
||||||
|
export * from "./build-proforma-detail-view-models";
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
import { type QueryKey, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { downloadProformaPDFApi } from "../api";
|
import { downloadProformaPDFApi } from "../api";
|
||||||
|
import { PROFORMA_PDF_QUERY_KEY } from "../../shared/hooks/keys";
|
||||||
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;
|
|
||||||
|
|
||||||
type DownloadProformaPDFOptions = {
|
type DownloadProformaPDFOptions = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -17,7 +16,7 @@ export function useDownloadProformaPDFQuery(
|
|||||||
const enabled = (options?.enabled ?? true) && !!proformaId;
|
const enabled = (options?.enabled ?? true) && !!proformaId;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: PROFORMA_QUERY_KEY(proformaId ?? "unknown"),
|
queryKey: PROFORMA_PDF_QUERY_KEY(proformaId),
|
||||||
queryFn: async (context) => {
|
queryFn: async (context) => {
|
||||||
if (!proformaId) throw new Error("proformaId is required");
|
if (!proformaId) throw new Error("proformaId is required");
|
||||||
|
|
||||||
@ -28,6 +27,7 @@ export function useDownloadProformaPDFQuery(
|
|||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -386,7 +386,7 @@ export function useProformasGridColumns(
|
|||||||
? undefined
|
? undefined
|
||||||
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
|
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
|
||||||
href: proforma.linkedInvoiceId
|
href: proforma.linkedInvoiceId
|
||||||
? `/issed-invoices/${proforma.linkedInvoiceId}`
|
? "/customer-invoices"
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -38,6 +38,12 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
|
|||||||
{ proformaId },
|
{ proformaId },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PROFORMAS_PDF_QUERY_KEY_PREFIX = ["proformas:pdf"] as const;
|
||||||
|
export const PROFORMA_PDF_QUERY_KEY = (proformaId?: string): QueryKey => [
|
||||||
|
...PROFORMAS_PDF_QUERY_KEY_PREFIX,
|
||||||
|
{ proformaId },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys para mutaciones
|
* Keys para mutaciones
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX } from "../../../issued-invoices/shared/hooks/keys";
|
||||||
import { ProformaToListRowPatchAdapter } from "../adapters";
|
import { ProformaToListRowPatchAdapter } from "../adapters";
|
||||||
import type { Proforma, ProformaList, ProformaListRow } from "../entities";
|
import type { Proforma, ProformaList, ProformaListRow } from "../entities";
|
||||||
|
|
||||||
import { LIST_PROFORMAS_QUERY_KEY_PREFIX, PROFORMA_QUERY_KEY } from "./keys";
|
import {
|
||||||
|
LIST_PROFORMAS_QUERY_KEY_PREFIX,
|
||||||
|
PROFORMA_PDF_QUERY_KEY,
|
||||||
|
PROFORMAS_PDF_QUERY_KEY_PREFIX,
|
||||||
|
PROFORMA_QUERY_KEY,
|
||||||
|
} from "./keys";
|
||||||
|
|
||||||
export interface ProformaListCacheSnapshot {
|
export interface ProformaListCacheSnapshot {
|
||||||
key: QueryKey;
|
key: QueryKey;
|
||||||
@ -28,6 +34,12 @@ export function invalidateProformaListQueries(queryClient: QueryClient) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateIssuedInvoiceListQueries(queryClient: QueryClient) {
|
||||||
|
return queryClient.invalidateQueries({
|
||||||
|
queryKey: LIST_ISSUED_INVOICES_QUERY_KEY_PREFIX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function invalidateProformaDetailQuery(queryClient: QueryClient, proformaId: string) {
|
export function invalidateProformaDetailQuery(queryClient: QueryClient, proformaId: string) {
|
||||||
return queryClient.invalidateQueries({
|
return queryClient.invalidateQueries({
|
||||||
queryKey: PROFORMA_QUERY_KEY(proformaId),
|
queryKey: PROFORMA_QUERY_KEY(proformaId),
|
||||||
@ -35,6 +47,20 @@ export function invalidateProformaDetailQuery(queryClient: QueryClient, proforma
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateProformaPdfQuery(queryClient: QueryClient, proformaId: string) {
|
||||||
|
return queryClient.invalidateQueries({
|
||||||
|
queryKey: PROFORMA_PDF_QUERY_KEY(proformaId),
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeProformaPdfQuery(queryClient: QueryClient, proformaId: string) {
|
||||||
|
return queryClient.removeQueries({
|
||||||
|
queryKey: PROFORMA_PDF_QUERY_KEY(proformaId),
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function setProformaDetailCache(
|
export function setProformaDetailCache(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
proformaId: string,
|
proformaId: string,
|
||||||
@ -50,6 +76,12 @@ export function removeProformaDetailCache(queryClient: QueryClient, proformaId:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateProformaPdfQueries(queryClient: QueryClient) {
|
||||||
|
return queryClient.invalidateQueries({
|
||||||
|
queryKey: PROFORMAS_PDF_QUERY_KEY_PREFIX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllProformaListQueryKeys(queryClient: QueryClient): QueryKey[] {
|
export function getAllProformaListQueryKeys(queryClient: QueryClient): QueryKey[] {
|
||||||
const entries = queryClient.getQueriesData<ProformaList>({
|
const entries = queryClient.getQueriesData<ProformaList>({
|
||||||
queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX,
|
queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX,
|
||||||
@ -132,6 +164,23 @@ export function syncUpdatedProformaCaches(queryClient: QueryClient, proforma: Pr
|
|||||||
setProformaDetailCache(queryClient, proforma.id, proforma);
|
setProformaDetailCache(queryClient, proforma.id, proforma);
|
||||||
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
invalidateProformaListQueries(queryClient),
|
||||||
|
invalidateProformaPdfQuery(queryClient, proforma.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncArchivedProformaCaches(queryClient: QueryClient, proforma: Proforma) {
|
||||||
|
setProformaDetailCache(queryClient, proforma.id, proforma);
|
||||||
|
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
||||||
|
|
||||||
|
return invalidateProformaListQueries(queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncUnarchivedProformaCaches(queryClient: QueryClient, proforma: Proforma) {
|
||||||
|
setProformaDetailCache(queryClient, proforma.id, proforma);
|
||||||
|
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
||||||
|
|
||||||
return invalidateProformaListQueries(queryClient);
|
return invalidateProformaListQueries(queryClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,15 +204,45 @@ export function rollbackDeleteProformaOptimisticUpdate(
|
|||||||
|
|
||||||
export function finalizeDeletedProformaCaches(queryClient: QueryClient, proformaId: string) {
|
export function finalizeDeletedProformaCaches(queryClient: QueryClient, proformaId: string) {
|
||||||
removeProformaDetailCache(queryClient, proformaId);
|
removeProformaDetailCache(queryClient, proformaId);
|
||||||
|
removeProformaPdfQuery(queryClient, proformaId);
|
||||||
return invalidateProformaListQueries(queryClient);
|
return invalidateProformaListQueries(queryClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cambio de estado e issue
|
// Cambio de estado e issue
|
||||||
|
|
||||||
export function syncChangedProformaStatusCaches(queryClient: QueryClient, proforma: Proforma) {
|
export async function syncChangedProformaStatusCaches(
|
||||||
return syncUpdatedProformaCaches(queryClient, proforma);
|
queryClient: QueryClient,
|
||||||
|
proformaId: string,
|
||||||
|
proforma?: Proforma
|
||||||
|
) {
|
||||||
|
if (proforma) {
|
||||||
|
setProformaDetailCache(queryClient, proforma.id, proforma);
|
||||||
|
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
||||||
|
} else {
|
||||||
|
await invalidateProformaDetailQuery(queryClient, proformaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
invalidateProformaListQueries(queryClient),
|
||||||
|
invalidateProformaPdfQuery(queryClient, proformaId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncIssuedProformaCaches(queryClient: QueryClient, proforma: Proforma) {
|
export async function syncIssuedProformaCaches(
|
||||||
return syncUpdatedProformaCaches(queryClient, proforma);
|
queryClient: QueryClient,
|
||||||
|
proformaId: string,
|
||||||
|
proforma?: Proforma
|
||||||
|
) {
|
||||||
|
if (proforma) {
|
||||||
|
setProformaDetailCache(queryClient, proforma.id, proforma);
|
||||||
|
upsertProformaInListCaches(queryClient, ProformaToListRowPatchAdapter.fromProforma(proforma));
|
||||||
|
} else {
|
||||||
|
await invalidateProformaDetailQuery(queryClient, proformaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
invalidateProformaListQueries(queryClient),
|
||||||
|
invalidateIssuedInvoiceListQueries(queryClient),
|
||||||
|
invalidateProformaPdfQuery(queryClient, proformaId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { type ArchiveProformaByIdParams, archiveProformaById } from "../api";
|
|||||||
import type { Proforma } from "../entities";
|
import type { Proforma } from "../entities";
|
||||||
|
|
||||||
import { ARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
|
import { ARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
|
||||||
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
|
import { syncArchivedProformaCaches } from "./proforma-cache-strategy";
|
||||||
|
|
||||||
export const useProformaArchiveMutation = () => {
|
export const useProformaArchiveMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -19,7 +19,7 @@ export const useProformaArchiveMutation = () => {
|
|||||||
return GetProformaByIdAdapter.fromDto(dto);
|
return GetProformaByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (proforma) => {
|
onSuccess: async (proforma) => {
|
||||||
await syncUpdatedProformaCaches(queryClient, proforma);
|
await syncArchivedProformaCaches(queryClient, proforma);
|
||||||
},
|
},
|
||||||
}) as ReturnType<typeof useMutation<Proforma, DefaultError, ArchiveProformaByIdParams>>;
|
}) as ReturnType<typeof useMutation<Proforma, DefaultError, ArchiveProformaByIdParams>>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
import { CHANGE_STATUS_PROFORMA_MUTATION_KEY } from "./keys";
|
import { CHANGE_STATUS_PROFORMA_MUTATION_KEY } from "./keys";
|
||||||
import { invalidateProformaListQueries } from "./proforma-cache-strategy";
|
import { syncChangedProformaStatusCaches } from "./proforma-cache-strategy";
|
||||||
import { toValidationErrors } from "./to-validation-errors";
|
import { toValidationErrors } from "./to-validation-errors";
|
||||||
|
|
||||||
type ChangeProformaStatusContext = {};
|
type ChangeProformaStatusContext = {};
|
||||||
@ -41,8 +41,8 @@ export const useProformaChangeStatusMutation = () => {
|
|||||||
|
|
||||||
return await changeProformaStatusById(dataSource, params);
|
return await changeProformaStatusById(dataSource, params);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (_result, { id: proformaId }) => {
|
||||||
await invalidateProformaListQueries(queryClient);
|
await syncChangedProformaStatusCaches(queryClient, proformaId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
import { ISSUE_PROFORMA_MUTATION_KEY } from "./keys";
|
import { ISSUE_PROFORMA_MUTATION_KEY } from "./keys";
|
||||||
import { invalidateProformaListQueries } from "./proforma-cache-strategy";
|
import { syncIssuedProformaCaches } from "./proforma-cache-strategy";
|
||||||
|
|
||||||
type IssueProformaByIdContext = {};
|
type IssueProformaByIdContext = {};
|
||||||
|
|
||||||
@ -32,8 +32,8 @@ export const useProformaIssueMutation = () => {
|
|||||||
|
|
||||||
return issueProformaById(dataSource, params);
|
return issueProformaById(dataSource, params);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (_result, { id: proformaId }) => {
|
||||||
await invalidateProformaListQueries(queryClient);
|
await syncIssuedProformaCaches(queryClient, proformaId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { GetProformaByIdAdapter } from "../adapters";
|
|||||||
import { type UnarchiveProformaByIdParams, unarchiveProformaById } from "../api";
|
import { type UnarchiveProformaByIdParams, unarchiveProformaById } from "../api";
|
||||||
|
|
||||||
import { UNARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
|
import { UNARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
|
||||||
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
|
import { syncUnarchivedProformaCaches } from "./proforma-cache-strategy";
|
||||||
|
|
||||||
export const useProformaUnarchiveMutation = () => {
|
export const useProformaUnarchiveMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -18,7 +18,7 @@ export const useProformaUnarchiveMutation = () => {
|
|||||||
return GetProformaByIdAdapter.fromDto(dto);
|
return GetProformaByIdAdapter.fromDto(dto);
|
||||||
},
|
},
|
||||||
onSuccess: async (proforma) => {
|
onSuccess: async (proforma) => {
|
||||||
await syncUpdatedProformaCaches(queryClient, proforma);
|
await syncUnarchivedProformaCaches(queryClient, proforma);
|
||||||
},
|
},
|
||||||
}) as ReturnType<
|
}) as ReturnType<
|
||||||
typeof useMutation<import("../entities").Proforma, DefaultError, UnarchiveProformaByIdParams>
|
typeof useMutation<import("../entities").Proforma, DefaultError, UnarchiveProformaByIdParams>
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
`identity` es el runtime nuevo de autenticación, sesión y acceso por memberships del ERP.
|
`identity` es el runtime nuevo de autenticación, sesión y acceso por memberships del ERP.
|
||||||
|
|
||||||
|
## Documentación canónica relacionada
|
||||||
|
|
||||||
|
- API de autenticación: [`../../docs/architecture/identity-auth-api.md`](../../docs/architecture/identity-auth-api.md)
|
||||||
|
- Contexto tenant y middlewares públicos: [`../../docs/architecture/tenant-context.md`](../../docs/architecture/tenant-context.md)
|
||||||
|
- Estado de migración: [`../../docs/architecture/identity-migration-status.md`](../../docs/architecture/identity-migration-status.md)
|
||||||
|
|
||||||
## Responsabilidades
|
## Responsabilidades
|
||||||
|
|
||||||
Gestiona:
|
Gestiona:
|
||||||
@ -37,11 +43,13 @@ El endpoint para companies accesibles es:
|
|||||||
GET /companies/available
|
GET /companies/available
|
||||||
```
|
```
|
||||||
|
|
||||||
Ese endpoint pertenece al flujo actual, pero no implica que `identity` deba exponer `GET /identity/companies`.
|
Ese endpoint pertenece al flujo actual. Las reglas detalladas del flujo y los endpoints no válidos se mantienen en la documentación de arquitectura.
|
||||||
|
|
||||||
## Access token
|
## Access token
|
||||||
|
|
||||||
El access token contiene solo:
|
El access token contiene solo identidad de cuenta.
|
||||||
|
|
||||||
|
Shape actual:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
@ -50,26 +58,11 @@ El access token contiene solo:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
No debe contener:
|
Las restricciones detalladas sobre claims y contexto tenant se documentan en `tenant-context.md`.
|
||||||
|
|
||||||
- `companyId`
|
|
||||||
- `companySlug`
|
|
||||||
- roles
|
|
||||||
- permisos
|
|
||||||
|
|
||||||
## Middlewares públicos
|
## Middlewares públicos
|
||||||
|
|
||||||
Para rutas autenticadas sin tenant:
|
Los contratos públicos de `requireIdentityAuthenticated(params)` y `requireIdentityTenant(params)` se documentan en `tenant-context.md`.
|
||||||
|
|
||||||
```ts
|
|
||||||
router.use(...requireIdentityAuthenticated(params));
|
|
||||||
```
|
|
||||||
|
|
||||||
Para rutas tenant-scoped:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
router.use(...requireIdentityTenant(params));
|
|
||||||
```
|
|
||||||
|
|
||||||
Código backend nuevo no debe usar:
|
Código backend nuevo no debe usar:
|
||||||
|
|
||||||
@ -105,19 +98,11 @@ Responsabilidad:
|
|||||||
- `identity:general.companyAccess`
|
- `identity:general.companyAccess`
|
||||||
- `companies:general.finder`
|
- `companies:general.finder`
|
||||||
|
|
||||||
Validación esperada:
|
La validación esperada, la semántica de errores y el shape mínimo de `req.user` se mantienen en `tenant-context.md`.
|
||||||
|
|
||||||
1. access token válido
|
|
||||||
2. cuenta autenticable
|
|
||||||
3. `X-Company-Id` presente
|
|
||||||
4. `X-Company-Id` UUID válido
|
|
||||||
5. membership activa
|
|
||||||
6. company activa
|
|
||||||
7. `req.user.companyId` poblado
|
|
||||||
|
|
||||||
## Reglas de mantenimiento
|
## Reglas de mantenimiento
|
||||||
|
|
||||||
- Backend nuevo: usar `@erp/identity/api`.
|
- Backend nuevo: usar `@erp/identity/api`.
|
||||||
- No documentar ni crear `GET /identity/companies`.
|
- No duplicar aquí contratos que ya viven en documentación de arquitectura.
|
||||||
- No borrar `modules/auth` mientras existan consumidores legacy reales.
|
- No borrar `modules/auth` mientras existan consumidores legacy reales.
|
||||||
- `modules/supplier` sigue pendiente de migración si continúa usando el runtime legacy.
|
- `modules/supplier` sigue pendiente de migración si continúa usando el runtime legacy.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { RequestHandler } from "express";
|
|
||||||
import type { Result } from "@repo/rdx-utils";
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
|
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
Button,
|
Button,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -23,18 +26,37 @@ const FIT_ZOOM_VALUE = "fit";
|
|||||||
const PDF_PAGE_WIDTH = 794;
|
const PDF_PAGE_WIDTH = 794;
|
||||||
const PDF_PAGE_HEIGHT = 1123;
|
const PDF_PAGE_HEIGHT = 1123;
|
||||||
|
|
||||||
|
export interface PDFViewerLabels {
|
||||||
|
zoomOut: string;
|
||||||
|
zoomIn: string;
|
||||||
|
zoomSelect: string;
|
||||||
|
fit: string;
|
||||||
|
open: string;
|
||||||
|
download: string;
|
||||||
|
print: string;
|
||||||
|
loading: string;
|
||||||
|
refreshing: string;
|
||||||
|
errorTitle: string;
|
||||||
|
retry: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
type PDFViewerProps = {
|
type PDFViewerProps = {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
isRefreshing?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
defaultZoom?: number | "fit";
|
defaultZoom?: number | "fit";
|
||||||
zoomOptions?: number[];
|
zoomOptions?: number[];
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
onPrint?: () => void;
|
onPrint?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
labels?: Partial<PDFViewerLabels>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clampZoom = (value: number, zoomOptions: number[]) => {
|
const clampZoom = (value: number, zoomOptions: number[]) => {
|
||||||
@ -50,18 +72,37 @@ export function PDFViewer({
|
|||||||
description = "Documento PDF embebido en la pantalla.",
|
description = "Documento PDF embebido en la pantalla.",
|
||||||
className,
|
className,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
isRefreshing = false,
|
||||||
error = null,
|
error = null,
|
||||||
defaultZoom = FIT_ZOOM_VALUE,
|
defaultZoom = FIT_ZOOM_VALUE,
|
||||||
zoomOptions = DEFAULT_ZOOM_OPTIONS,
|
zoomOptions = DEFAULT_ZOOM_OPTIONS,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDownload,
|
onDownload,
|
||||||
onPrint,
|
onPrint,
|
||||||
|
onRetry,
|
||||||
|
labels,
|
||||||
}: PDFViewerProps) {
|
}: PDFViewerProps) {
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [fitZoom, setFitZoom] = useState(100);
|
const [fitZoom, setFitZoom] = useState(100);
|
||||||
const [zoom, setZoom] = useState<string>(() =>
|
const [zoom, setZoom] = useState<string>(() =>
|
||||||
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
|
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
|
||||||
);
|
);
|
||||||
|
const resolvedLabels: PDFViewerLabels = {
|
||||||
|
zoomOut: "Alejar",
|
||||||
|
zoomIn: "Acercar",
|
||||||
|
zoomSelect: "Seleccionar zoom",
|
||||||
|
fit: "Ajustar",
|
||||||
|
open: "Abrir PDF",
|
||||||
|
download: "Descargar PDF",
|
||||||
|
print: "Imprimir PDF",
|
||||||
|
loading: "Cargando vista previa del PDF...",
|
||||||
|
refreshing: "Actualizando documento...",
|
||||||
|
errorTitle: "No se pudo cargar la vista previa",
|
||||||
|
retry: "Reintentar",
|
||||||
|
emptyTitle: "Vista previa no disponible",
|
||||||
|
emptyDescription: "El PDF del documento todavía no está listo para mostrarse.",
|
||||||
|
...labels,
|
||||||
|
};
|
||||||
|
|
||||||
const resolvedSrc = useMemo(() => {
|
const resolvedSrc = useMemo(() => {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
@ -156,6 +197,7 @@ export function PDFViewer({
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={resolvedLabels.zoomOut}
|
||||||
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
|
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setZoom((current) => {
|
setZoom((current) => {
|
||||||
@ -174,11 +216,11 @@ export function PDFViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Select onValueChange={setZoom} value={zoom}>
|
<Select onValueChange={setZoom} value={zoom}>
|
||||||
<SelectTrigger className="w-24 bg-background">
|
<SelectTrigger aria-label={resolvedLabels.zoomSelect} className="w-24 bg-background">
|
||||||
<SelectValue placeholder="Zoom" />
|
<SelectValue placeholder={resolvedLabels.zoomSelect} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={FIT_ZOOM_VALUE}>Ajustar</SelectItem>
|
<SelectItem value={FIT_ZOOM_VALUE}>{resolvedLabels.fit}</SelectItem>
|
||||||
{zoomOptions.map((option) => (
|
{zoomOptions.map((option) => (
|
||||||
<SelectItem key={option} value={String(option)}>
|
<SelectItem key={option} value={String(option)}>
|
||||||
{option}%
|
{option}%
|
||||||
@ -188,6 +230,7 @@ export function PDFViewer({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={resolvedLabels.zoomIn}
|
||||||
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
|
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setZoom((current) => {
|
setZoom((current) => {
|
||||||
@ -207,7 +250,7 @@ export function PDFViewer({
|
|||||||
|
|
||||||
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
|
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
|
||||||
<ExternalLinkIcon className="mr-2 size-4" />
|
<ExternalLinkIcon className="mr-2 size-4" />
|
||||||
Abrir PDF
|
{resolvedLabels.open}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -217,10 +260,11 @@ export function PDFViewer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="mr-2 size-4" />
|
<DownloadIcon className="mr-2 size-4" />
|
||||||
Descargar
|
{resolvedLabels.download}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
aria-label={resolvedLabels.print}
|
||||||
disabled={loading || !src}
|
disabled={loading || !src}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@ -229,6 +273,13 @@ export function PDFViewer({
|
|||||||
>
|
>
|
||||||
<PrinterIcon className="size-4" />
|
<PrinterIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isRefreshing && src ? (
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
<span>{resolvedLabels.refreshing}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="shadow-sm"
|
className="shadow-sm"
|
||||||
@ -243,17 +294,24 @@ export function PDFViewer({
|
|||||||
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80">
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80">
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
Cargando vista previa del PDF...
|
{resolvedLabels.loading}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && error ? (
|
{!loading && error ? (
|
||||||
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
||||||
<div className="space-y-2">
|
<Alert className="max-w-md text-left">
|
||||||
<p className="font-medium text-foreground">No se pudo cargar la vista previa</p>
|
<AlertTitle>{resolvedLabels.errorTitle}</AlertTitle>
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
<AlertDescription className="space-y-4">
|
||||||
</div>
|
<p>{error}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button onClick={onRetry} type="button" variant="outline">
|
||||||
|
{resolvedLabels.retry}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -280,9 +338,9 @@ export function PDFViewer({
|
|||||||
{loading || error || resolvedSrc ? null : (
|
{loading || error || resolvedSrc ? null : (
|
||||||
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="font-medium text-foreground">Vista previa no disponible</p>
|
<p className="font-medium text-foreground">{resolvedLabels.emptyTitle}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
El PDF de la proforma todavía no está listo para mostrarse.
|
{resolvedLabels.emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user