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:
David Arranz 2026-08-05 13:17:55 +02:00
parent 32598c174c
commit 9cade597d7
52 changed files with 2417 additions and 1013 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
*/ */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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