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_not_found_message": "The proforma no longer exists or is not available.",
"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": {
@ -273,8 +393,7 @@
"description": "Delete the selected customer 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": {
"title": "View 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_not_found_message": "La proforma ya no existe o no está disponible.",
"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": {
@ -274,8 +394,7 @@
"description": "Eliminar la proforma seleccionada"
},
"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": {
"title": "Ver proforma",
"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 {
getPaymentMethodOptions,
usePaymentMethodsListQuery,
} from "@erp/catalogs/client/payment-methods";
import {
getPaymentTermOptions,
usePaymentTermsListQuery,
} from "@erp/catalogs/client/payment-terms";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useEffect, useMemo, useState } from "react";
import { createSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useDownloadProformaPDFController, useDownloadProformaPDFQuery } from "../../download-pdf";
import { useIssueProformaDialogController } from "../../issue-proforma";
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
import {
useDuplicateProformaByIdMutation,
useProformaArchiveMutation,
useProformaGetQuery,
useProformaUnarchiveMutation,
} from "../../shared";
const PROFORMAS_FALLBACK_PATH = "/proformas";
const ACTIVE_ONLY_FILTERS = [
{
field: "isActive",
operator: "EQUALS" as const,
value: "true",
},
];
export const useProformaDetailPageController = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { id = "" } = useParams<{ id: string }>();
const [previewZoom, setPreviewZoom] = useState("fit");
const { currentReturnTo, navigateBack } = useReturnToNavigation({
fallbackPath: PROFORMAS_FALLBACK_PATH,
});
const proformaQuery = useProformaGetQuery({
id,
enabled: Boolean(id),
});
const issueDialogCtrl = useIssueProformaDialogController();
const downloadCtrl = useDownloadProformaPDFController();
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
enabled: Boolean(id),
});
const duplicateMutation = useDuplicateProformaByIdMutation();
const archiveMutation = useProformaArchiveMutation();
const unarchiveMutation = useProformaUnarchiveMutation();
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
useEffect(() => {
if (!previewPdfQuery.data) {
setPreviewPdfUrl(null);
return;
}
const objectUrl = URL.createObjectURL(previewPdfQuery.data);
setPreviewPdfUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [previewPdfQuery.data]);
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
}, [paymentMethodsQuery.data?.items]);
const paymentTermOptions = useMemo(() => {
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
}, [paymentTermsQuery.data?.items]);
const proforma = proformaQuery.data;
const paymentMethodLabel =
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
null;
const paymentTermLabel =
paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null;
const handleEdit = () => {
if (!proforma) {
return;
}
navigate({
pathname: `/proformas/${proforma.id}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const handleDownload = () => {
if (!proforma) {
return;
}
downloadCtrl.download(proforma.id, proforma.proformaReference);
};
const handleOpenPreviewPdf = () => {
if (previewPdfUrl) {
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
return;
}
handleDownload();
};
const handlePrintPreview = () => {
handleOpenPreviewPdf();
};
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 primaryAction = useMemo(() => {
if (!proforma) {
return {
label: "Editar",
onClick: () => undefined,
};
}
if (proforma.isArchived) {
return {
label: "Desarchivar",
onClick: handleArchiveToggle,
};
}
if (proforma.status === "approved") {
return {
label: "Emitir factura",
onClick: () => issueDialogCtrl.openDialog(prepareIssueProformaTarget(proforma)),
};
}
return {
label: "Editar",
onClick: handleEdit,
};
}, [handleArchiveToggle, issueDialogCtrl, proforma]);
return {
id,
proforma,
proformaQuery,
paymentMethodLabel,
paymentTermLabel,
previewZoom,
setPreviewZoom,
previewPdfQuery,
previewPdfUrl,
currentReturnTo,
navigateBack,
primaryAction,
issueDialogCtrl,
downloadCtrl,
duplicateMutation,
archiveMutation,
unarchiveMutation,
handleEdit,
handleDownload,
handleOpenPreviewPdf,
handlePrintPreview,
handleDuplicate,
handleArchiveToggle,
};
};
import {
getPaymentMethodOptions,
usePaymentMethodsListQuery,
} from "@erp/catalogs/client/payment-methods";
import {
getPaymentTermOptions,
usePaymentTermsListQuery,
} from "@erp/catalogs/client/payment-terms";
import { useReturnToNavigation } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useEffect, useMemo, useState } from "react";
import { createSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useChangeProformaStatusDialogController } from "../../change-status";
import { useDownloadProformaPDFQuery } from "../../download-pdf";
import { useIssueProformaDialogController } from "../../issue-proforma";
import { prepareIssueProformaTarget } from "../../issue-proforma/utils";
import {
useDuplicateProformaByIdMutation,
useProformaArchiveMutation,
useProformaGetQuery,
useProformaUnarchiveMutation,
} from "../../shared";
import type {
ProformaDetailActionKey,
ProformaDetailActionsViewModel,
ProformaDetailDocumentState,
ProformaDetailDocumentViewModel,
ProformaDetailGeneralViewModel,
ProformaDetailHeaderViewModel,
ProformaDetailPageState,
} from "../entities";
import {
buildProformaDetailActions,
buildProformaDetailDocumentViewModel,
buildProformaDetailGeneralViewModel,
buildProformaDetailHeaderViewModel,
} from "../utils";
const PROFORMAS_FALLBACK_PATH = "/proformas";
const INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g;
const ACTIVE_ONLY_FILTERS = [
{
field: "isActive",
operator: "EQUALS" as const,
value: "true",
},
];
const isUsablePdfBlob = (value: Blob | undefined): value is Blob => {
return value instanceof Blob && value.size > 0;
};
const buildProformaPdfFilename = (reference: string) => {
const sanitizedReference = reference
.trim()
.replace(INVALID_FILENAME_CHARACTERS, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return `proforma-${sanitizedReference || "documento"}.pdf`;
};
const triggerBlobDownload = (blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
window.setTimeout(() => {
URL.revokeObjectURL(objectUrl);
}, 0);
};
export const useProformaDetailPageController = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { id = "" } = useParams<{ id: string }>();
const { currentReturnTo, navigateBack } = useReturnToNavigation({
fallbackPath: PROFORMAS_FALLBACK_PATH,
});
const proformaQuery = useProformaGetQuery({
id,
enabled: Boolean(id),
});
const issueDialogCtrl = useIssueProformaDialogController();
const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
const previewPdfQuery = useDownloadProformaPDFQuery(id, {
enabled: Boolean(id),
});
const duplicateMutation = useDuplicateProformaByIdMutation();
const archiveMutation = useProformaArchiveMutation();
const unarchiveMutation = useProformaUnarchiveMutation();
const previewPdfBlob = isUsablePdfBlob(previewPdfQuery.data) ? previewPdfQuery.data : null;
const [previewPdfUrl, setPreviewPdfUrl] = useState<string | null>(null);
useEffect(() => {
if (!previewPdfBlob) {
setPreviewPdfUrl(null);
return;
}
const objectUrl = URL.createObjectURL(previewPdfBlob);
setPreviewPdfUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
}, [previewPdfBlob]);
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
pageSize: 999,
filters: ACTIVE_ONLY_FILTERS,
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
}, [paymentMethodsQuery.data?.items]);
const paymentTermOptions = useMemo(() => {
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
}, [paymentTermsQuery.data?.items]);
const proforma = proformaQuery.data;
const paymentMethodLabel =
paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ??
null;
const paymentTermLabel =
paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null;
const paymentContext = useMemo(
() => ({
translate: (key: string, params?: Record<string, string | number | null | undefined>) =>
t(key, params),
paymentMethodLabel,
paymentTermLabel,
}),
[paymentMethodLabel, paymentTermLabel, t]
);
const state: ProformaDetailPageState = {
isInitialLoading: proformaQuery.isLoading,
isRefetching: proformaQuery.isFetching && !proformaQuery.isLoading,
isError: proformaQuery.isError,
isNotFound: !(proformaQuery.isLoading || proformaQuery.isError || proforma),
errorMessage: proformaQuery.isError
? proformaQuery.error instanceof Error
? proformaQuery.error.message
: t("pages.proformas.update.load_error.message")
: null,
};
const documentState: ProformaDetailDocumentState = {
isInitialLoading: previewPdfQuery.isLoading && !previewPdfBlob,
isFetching: previewPdfQuery.isFetching && !previewPdfQuery.isLoading,
isError: previewPdfQuery.isError,
errorMessage: previewPdfQuery.isError
? previewPdfQuery.error instanceof Error
? previewPdfQuery.error.message
: t("proformas.detail.document.error")
: null,
};
const handleEdit = () => {
if (!proforma) {
return;
}
navigate({
pathname: `/proformas/${proforma.id}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const handleDownload = async () => {
if (!proforma) {
return;
}
const filename = buildProformaPdfFilename(proforma.proformaReference);
if (previewPdfBlob) {
triggerBlobDownload(previewPdfBlob, filename);
return;
}
const result = await previewPdfQuery.refetch();
if (result.error) {
showErrorToast(
t("proformas.detail.document.error_title"),
result.error instanceof Error ? result.error.message : t("proformas.detail.document.error")
);
return;
}
if (!isUsablePdfBlob(result.data)) {
showErrorToast(
t("proformas.detail.document.empty_title"),
t("proformas.detail.document.empty_description")
);
return;
}
triggerBlobDownload(result.data, filename);
};
const handleOpenCustomer = () => {
if (!proforma?.customerId) {
return;
}
navigate({
pathname: `/customers/${proforma.customerId}`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const handleOpenPreviewPdf = () => {
if (!previewPdfUrl) {
return;
}
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
};
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 "./entities";
export * from "./ui";
export * from "./utils";

View File

@ -1,4 +1,6 @@
export * from "./proforma-detail-header-actions";
export * from "./proforma-detail-preview-card";
export * from "./proforma-detail-sidebar";
export * from "./proforma-detail-summary-card";
export * from "./proforma-detail-content";
export * from "./proforma-detail-header";
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 { DownloadIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon } from "lucide-react";
import {
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 = {
primaryAction: {
label: string;
onClick: () => void;
};
isPrimaryLoading: boolean;
isArchived: boolean;
onDownload: () => void;
onDuplicate: () => void;
onArchiveToggle: () => void;
actions: ProformaDetailActionsViewModel;
onExecuteAction: (action: ProformaDetailActionKey) => void;
};
export const ProformaDetailHeaderActions = ({
primaryAction,
isPrimaryLoading,
isArchived,
onDownload,
onDuplicate,
onArchiveToggle,
actions,
onExecuteAction,
}: 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 (
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Button disabled={isPrimaryLoading} onClick={primaryAction.onClick} type="button">
{isPrimaryLoading ? (
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
) : (
<PencilIcon className="mr-2 size-4" />
)}
{primaryAction.label}
</Button>
{primaryAction ? (
<Button
disabled={!primaryAction.enabled}
onClick={() => onExecuteAction(primaryAction.key)}
type="button"
>
{primaryAction.loading ? (
<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">
<DownloadIcon className="mr-2 size-4" />
Descargar PDF
</Button>
{secondaryActions.map((action) => {
const Icon = getActionIcon(action.key);
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" type="button" variant="outline">
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={onDuplicate}>Duplicar proforma</DropdownMenuItem>
<DropdownMenuItem onClick={onArchiveToggle}>
{isArchived ? "Desarchivar" : "Archivar"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
return (
<Button
disabled={!action.enabled}
key={action.key}
onClick={() => onExecuteAction(action.key)}
type="button"
variant="outline"
>
<Icon className="mr-2 size-4" />
{action.label}
</Button>
);
})}
{menuActions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
size="icon"
type="button"
variant="outline"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
{menuActions.map((action) => (
<DropdownMenuItem
disabled={!action.enabled}
key={action.key}
onClick={() => onExecuteAction(action.key)}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
};

View File

@ -0,0 +1,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-billing-inspector-card";
export * from "./proforma-detail-dates-inspector-card";
export * from "./proforma-detail-field";
export * from "./proforma-detail-header-meta";
export * from "./proforma-detail-inspector-card";
export * from "./proforma-detail-notes-inspector-card";
export * from "./proforma-detail-section";
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";
type ProformaDetailArchivedBadgeProps = {
label: string;
className?: string;
};
export const ProformaDetailArchivedBadge = ({
label,
className,
}: ProformaDetailArchivedBadgeProps) => {
return (
@ -21,7 +23,7 @@ export const ProformaDetailArchivedBadge = ({
)}
>
<ArchiveIcon aria-hidden="true" />
Archivada
{label}
</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 type { Proforma } from "../../../shared";
import { formatProformaDate, formatProformaMoney } from "./proforma-detail-formatters";
type ProformaDetailHeaderMetaProps = {
proforma: Proforma;
customerName: string;
documentReference: string;
};
export const ProformaDetailHeaderMeta = ({ proforma }: ProformaDetailHeaderMetaProps) => {
export const ProformaDetailHeaderMeta = ({
customerName,
documentReference,
}: ProformaDetailHeaderMetaProps) => {
const items = useMemo(
() => [
proforma.recipient.name ?? "Cliente sin nombre",
formatProformaDate(proforma.proformaDate),
`Total ${formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode)}`,
],
[proforma]
() => [customerName, documentReference],
[customerName, documentReference]
);
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) => (
<div className="flex items-center gap-3" key={`${item}-${index}`}>
{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 = () => {
return (
<div className="space-y-6 p-6">
<Card className="shadow-sm">
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-10 w-96" />
<Skeleton className="h-5 w-80" />
</CardContent>
</Card>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-muted/30">
<div className="shrink-0 border-b bg-background px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="size-10 rounded-md" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-56" />
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.8fr)_360px]">
<Card className="shadow-sm">
<CardContent className="space-y-4 p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-[840px] w-full" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-28" />
<Skeleton className="h-10 w-28" />
<Skeleton className="size-10 rounded-md" />
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-44 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-36 w-full rounded-xl" />
<div className="mt-4 flex flex-wrap items-center gap-3">
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-7 w-28 rounded-full" />
<Skeleton className="h-4 w-48" />
</div>
</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>

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 "./components";
export * from "./pages";
export * from "./sections";

View File

@ -1,121 +1,102 @@
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { IssueProformaDialog } from "../../../issue-proforma";
import { ProformaStatusBadge } from "../../../list/ui/components";
import { ProformaPageHeader } from "../../../shared";
import { useProformaDetailPageController } from "../../controllers";
import {
ProformaDetailHeaderActions,
ProformaDetailPreviewCard,
ProformaDetailSidebar,
} from "../blocks";
import {
ProformaDetailArchivedBadge,
ProformaDetailHeaderMeta,
ProformaDetailSkeleton,
} from "../components";
export const ProformaDetailPage = () => {
const ctrl = useProformaDetailPageController();
if (ctrl.proformaQuery.isLoading) {
return <ProformaDetailSkeleton />;
}
if (ctrl.proformaQuery.isError) {
return (
<AppContent>
<ErrorAlert
message={
ctrl.proformaQuery.error instanceof Error
? ctrl.proformaQuery.error.message
: "No se pudo cargar la proforma."
}
title="Error al cargar la proforma"
/>
<div className="flex items-center justify-end">
<BackHistoryButton onClick={() => ctrl.navigateBack()} />
</div>
</AppContent>
);
}
if (!ctrl.proforma) {
return (
<AppContent>
<NotFoundCard
message="Revisa el identificador o vuelve al listado de proformas."
title="Proforma no encontrada"
/>
</AppContent>
);
}
return (
<div className="flex h-full flex-col">
<ProformaPageHeader
actions={
<ProformaDetailHeaderActions
isArchived={ctrl.proforma.isArchived}
isPrimaryLoading={ctrl.issueDialogCtrl.isSubmitting}
onArchiveToggle={ctrl.handleArchiveToggle}
onDownload={ctrl.handleDownload}
onDuplicate={ctrl.handleDuplicate}
primaryAction={ctrl.primaryAction}
/>
}
backLabel="Volver al listado"
onBack={ctrl.navigateBack}
title={<>{`Proforma ${ctrl.proforma.proformaReference}`}</>}
>
<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} />
</div>
</div>
</ProformaPageHeader>
<div className="flex-1 overflow-auto p-6">
<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={
ctrl.previewPdfQuery.isError
? ctrl.previewPdfQuery.error instanceof Error
? ctrl.previewPdfQuery.error.message
: "No se pudo cargar el PDF."
: null
}
isLoading={ctrl.previewPdfQuery.isLoading || ctrl.previewPdfQuery.isFetching}
onDownload={ctrl.handleDownload}
onOpen={ctrl.handleOpenPreviewPdf}
onPrint={ctrl.handlePrintPreview}
src={ctrl.previewPdfUrl}
/>
<ProformaDetailSidebar
onEdit={ctrl.handleEdit}
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>
);
};
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { useTranslation } from "../../../../i18n";
import { ChangeProformaStatusDialog } from "../../../change-status";
import { IssueProformaDialog } from "../../../issue-proforma";
import { useProformaDetailPageController } from "../../controllers";
import {
ProformaDetailContent,
ProformaDetailHeader,
ProformaDetailInspector,
ProformaDocumentPane,
} from "../blocks";
import { ProformaDetailSkeleton } from "../components";
export const ProformaDetailPage = () => {
const { t } = useTranslation();
const ctrl = useProformaDetailPageController();
if (ctrl.state.isInitialLoading) {
return <ProformaDetailSkeleton />;
}
if (ctrl.state.isError) {
return (
<div className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-3xl rounded-lg border bg-background p-6">
<ErrorAlert
message={ctrl.state.errorMessage ?? t("pages.proformas.update.load_error.message")}
title={t("pages.proformas.update.load_error.title")}
/>
</div>
</div>
);
}
if (ctrl.state.isNotFound || !(ctrl.header && ctrl.general && ctrl.document)) {
return (
<div className="p-4 sm:p-6">
<div className="mx-auto w-full max-w-3xl">
<NotFoundCard
message={t("pages.proformas.update.not_found_msg")}
title={t("pages.proformas.update.not_found_title")}
/>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<ProformaDetailHeader
actions={ctrl.actions}
header={ctrl.header}
onBack={ctrl.navigation.navigateBack}
onExecuteAction={ctrl.handlers.executeAction}
/>
<ProformaDetailContent
documentPane={
<ProformaDocumentPane
document={ctrl.document}
onDownload={ctrl.viewer.handleDownload}
onOpen={ctrl.viewer.handleOpenPreviewPdf}
onPrint={ctrl.viewer.handlePrintPreview}
onRetry={ctrl.viewer.handleRetryDocument}
state={ctrl.documentState}
/>
}
inspector={
<ProformaDetailInspector
general={ctrl.general}
onOpenCustomer={ctrl.navigation.openCustomer}
/>
}
/>
<IssueProformaDialog
isSubmitting={ctrl.dialogs.issue.isSubmitting}
onConfirm={ctrl.dialogs.issue.confirmIssue}
onOpenChange={(open) => {
if (!open) {
ctrl.dialogs.issue.closeDialog();
}
}}
open={ctrl.dialogs.issue.open}
target={ctrl.dialogs.issue.target}
/>
<ChangeProformaStatusDialog
isSubmitting={ctrl.dialogs.changeStatus.isSubmitting}
onConfirm={ctrl.dialogs.changeStatus.confirmChangeStatus}
onOpenChange={(open) => {
if (!open) {
ctrl.dialogs.changeStatus.closeDialog();
}
}}
open={ctrl.dialogs.changeStatus.open}
targets={ctrl.dialogs.changeStatus.targets}
/>
</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 { type QueryKey, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { downloadProformaPDFApi } from "../api";
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;
import { PROFORMA_PDF_QUERY_KEY } from "../../shared/hooks/keys";
type DownloadProformaPDFOptions = {
enabled?: boolean;
@ -17,7 +16,7 @@ export function useDownloadProformaPDFQuery(
const enabled = (options?.enabled ?? true) && !!proformaId;
return useQuery({
queryKey: PROFORMA_QUERY_KEY(proformaId ?? "unknown"),
queryKey: PROFORMA_PDF_QUERY_KEY(proformaId),
queryFn: async (context) => {
if (!proformaId) throw new Error("proformaId is required");
@ -28,6 +27,7 @@ export function useDownloadProformaPDFQuery(
},
enabled,
staleTime: 0,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@ -386,7 +386,7 @@ export function useProformasGridColumns(
? undefined
: t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable"),
href: proforma.linkedInvoiceId
? `/issed-invoices/${proforma.linkedInvoiceId}`
? "/customer-invoices"
: undefined,
},
{

View File

@ -38,6 +38,12 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
{ 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
*/

View File

@ -1,9 +1,15 @@
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 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 {
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) {
return queryClient.invalidateQueries({
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(
queryClient: QueryClient,
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[] {
const entries = queryClient.getQueriesData<ProformaList>({
queryKey: LIST_PROFORMAS_QUERY_KEY_PREFIX,
@ -132,6 +164,23 @@ export function syncUpdatedProformaCaches(queryClient: QueryClient, proforma: Pr
setProformaDetailCache(queryClient, proforma.id, 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);
}
@ -155,15 +204,45 @@ export function rollbackDeleteProformaOptimisticUpdate(
export function finalizeDeletedProformaCaches(queryClient: QueryClient, proformaId: string) {
removeProformaDetailCache(queryClient, proformaId);
removeProformaPdfQuery(queryClient, proformaId);
return invalidateProformaListQueries(queryClient);
}
// Cambio de estado e issue
export function syncChangedProformaStatusCaches(queryClient: QueryClient, proforma: Proforma) {
return syncUpdatedProformaCaches(queryClient, proforma);
export async function syncChangedProformaStatusCaches(
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) {
return syncUpdatedProformaCaches(queryClient, proforma);
export async function syncIssuedProformaCaches(
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 { ARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
import { syncArchivedProformaCaches } from "./proforma-cache-strategy";
export const useProformaArchiveMutation = () => {
const queryClient = useQueryClient();
@ -19,7 +19,7 @@ export const useProformaArchiveMutation = () => {
return GetProformaByIdAdapter.fromDto(dto);
},
onSuccess: async (proforma) => {
await syncUpdatedProformaCaches(queryClient, proforma);
await syncArchivedProformaCaches(queryClient, proforma);
},
}) as ReturnType<typeof useMutation<Proforma, DefaultError, ArchiveProformaByIdParams>>;
};

View File

@ -10,7 +10,7 @@ import {
} from "../api";
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";
type ChangeProformaStatusContext = {};
@ -41,8 +41,8 @@ export const useProformaChangeStatusMutation = () => {
return await changeProformaStatusById(dataSource, params);
},
onSuccess: async () => {
await invalidateProformaListQueries(queryClient);
onSuccess: async (_result, { id: proformaId }) => {
await syncChangedProformaStatusCaches(queryClient, proformaId);
},
});
};

View File

@ -8,7 +8,7 @@ import {
} from "../api";
import { ISSUE_PROFORMA_MUTATION_KEY } from "./keys";
import { invalidateProformaListQueries } from "./proforma-cache-strategy";
import { syncIssuedProformaCaches } from "./proforma-cache-strategy";
type IssueProformaByIdContext = {};
@ -32,8 +32,8 @@ export const useProformaIssueMutation = () => {
return issueProformaById(dataSource, params);
},
onSuccess: async () => {
await invalidateProformaListQueries(queryClient);
onSuccess: async (_result, { id: proformaId }) => {
await syncIssuedProformaCaches(queryClient, proformaId);
},
});
};

View File

@ -5,7 +5,7 @@ import { GetProformaByIdAdapter } from "../adapters";
import { type UnarchiveProformaByIdParams, unarchiveProformaById } from "../api";
import { UNARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
import { syncUnarchivedProformaCaches } from "./proforma-cache-strategy";
export const useProformaUnarchiveMutation = () => {
const queryClient = useQueryClient();
@ -18,7 +18,7 @@ export const useProformaUnarchiveMutation = () => {
return GetProformaByIdAdapter.fromDto(dto);
},
onSuccess: async (proforma) => {
await syncUpdatedProformaCaches(queryClient, proforma);
await syncUnarchivedProformaCaches(queryClient, proforma);
},
}) as ReturnType<
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.
## 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
Gestiona:
@ -37,11 +43,13 @@ El endpoint para companies accesibles es:
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
El access token contiene solo:
El access token contiene solo identidad de cuenta.
Shape actual:
```ts
{
@ -50,26 +58,11 @@ El access token contiene solo:
}
```
No debe contener:
- `companyId`
- `companySlug`
- roles
- permisos
Las restricciones detalladas sobre claims y contexto tenant se documentan en `tenant-context.md`.
## Middlewares públicos
Para rutas autenticadas sin tenant:
```ts
router.use(...requireIdentityAuthenticated(params));
```
Para rutas tenant-scoped:
```ts
router.use(...requireIdentityTenant(params));
```
Los contratos públicos de `requireIdentityAuthenticated(params)` y `requireIdentityTenant(params)` se documentan en `tenant-context.md`.
Código backend nuevo no debe usar:
@ -105,19 +98,11 @@ Responsabilidad:
- `identity:general.companyAccess`
- `companies:general.finder`
Validación esperada:
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
La validación esperada, la semántica de errores y el shape mínimo de `req.user` se mantienen en `tenant-context.md`.
## Reglas de mantenimiento
- 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.
- `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 { RequestHandler } from "express";
import type { IIdentityCompanyAccessChecker } from "../company-memberships";

View File

@ -1,5 +1,8 @@
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Select,
SelectContent,
@ -23,18 +26,37 @@ const FIT_ZOOM_VALUE = "fit";
const PDF_PAGE_WIDTH = 794;
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 = {
src?: string | null;
title?: string;
description?: string;
className?: string;
loading?: boolean;
isRefreshing?: boolean;
error?: string | null;
defaultZoom?: number | "fit";
zoomOptions?: number[];
onOpen?: () => void;
onDownload?: () => void;
onPrint?: () => void;
onRetry?: () => void;
labels?: Partial<PDFViewerLabels>;
};
const clampZoom = (value: number, zoomOptions: number[]) => {
@ -50,18 +72,37 @@ export function PDFViewer({
description = "Documento PDF embebido en la pantalla.",
className,
loading = false,
isRefreshing = false,
error = null,
defaultZoom = FIT_ZOOM_VALUE,
zoomOptions = DEFAULT_ZOOM_OPTIONS,
onOpen,
onDownload,
onPrint,
onRetry,
labels,
}: PDFViewerProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const [fitZoom, setFitZoom] = useState(100);
const [zoom, setZoom] = useState<string>(() =>
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(() => {
if (!src) {
@ -156,6 +197,7 @@ export function PDFViewer({
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
aria-label={resolvedLabels.zoomOut}
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
onClick={() =>
setZoom((current) => {
@ -174,11 +216,11 @@ export function PDFViewer({
</Button>
<Select onValueChange={setZoom} value={zoom}>
<SelectTrigger className="w-24 bg-background">
<SelectValue placeholder="Zoom" />
<SelectTrigger aria-label={resolvedLabels.zoomSelect} className="w-24 bg-background">
<SelectValue placeholder={resolvedLabels.zoomSelect} />
</SelectTrigger>
<SelectContent>
<SelectItem value={FIT_ZOOM_VALUE}>Ajustar</SelectItem>
<SelectItem value={FIT_ZOOM_VALUE}>{resolvedLabels.fit}</SelectItem>
{zoomOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}%
@ -188,6 +230,7 @@ export function PDFViewer({
</Select>
<Button
aria-label={resolvedLabels.zoomIn}
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
onClick={() =>
setZoom((current) => {
@ -207,7 +250,7 @@ export function PDFViewer({
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
<ExternalLinkIcon className="mr-2 size-4" />
Abrir PDF
{resolvedLabels.open}
</Button>
<Button
@ -217,10 +260,11 @@ export function PDFViewer({
variant="outline"
>
<DownloadIcon className="mr-2 size-4" />
Descargar
{resolvedLabels.download}
</Button>
<Button
aria-label={resolvedLabels.print}
disabled={loading || !src}
onClick={handlePrint}
size="icon-sm"
@ -229,6 +273,13 @@ export function PDFViewer({
>
<PrinterIcon className="size-4" />
</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>
}
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 items-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-4" />
Cargando vista previa del PDF...
{resolvedLabels.loading}
</div>
</div>
) : null}
{!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="space-y-2">
<p className="font-medium text-foreground">No se pudo cargar la vista previa</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
<Alert className="max-w-md text-left">
<AlertTitle>{resolvedLabels.errorTitle}</AlertTitle>
<AlertDescription className="space-y-4">
<p>{error}</p>
{onRetry ? (
<Button onClick={onRetry} type="button" variant="outline">
{resolvedLabels.retry}
</Button>
) : null}
</AlertDescription>
</Alert>
</div>
) : null}
@ -280,9 +338,9 @@ export function PDFViewer({
{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="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">
El PDF de la proforma todavía no está listo para mostrarse.
{resolvedLabels.emptyDescription}
</p>
</div>
</div>