feat: add proforma detail components and utilities
- Introduced ProformaDetailContent, ProformaDetailHeader, ProformaDetailInspector, ProformaDocumentPane, ProformaGeneralPanel components for displaying detailed proforma information. - Created various sections including ProformaCommercialSection, ProformaContentSection, ProformaCustomerSection, ProformaDatesSection, ProformaFiscalSection, ProformaLinkedInvoiceSection, and ProformaSummarySection to organize proforma details. - Implemented utility functions to build proforma detail actions and view models, enhancing the management of proforma states and actions. - Added translations and improved accessibility features across components.
This commit is contained in:
parent
32598c174c
commit
9cade597d7
@ -183,6 +183,126 @@
|
||||
"error_title": "The proforma could not be duplicated",
|
||||
"error_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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-detail-view-models.entity";
|
||||
@ -0,0 +1,148 @@
|
||||
import type { ProformaStatus } from "../../shared";
|
||||
|
||||
export interface ProformaDetailPageState {
|
||||
isInitialLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
isError: boolean;
|
||||
isNotFound: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailHeaderViewModel {
|
||||
title: string;
|
||||
customerName: string;
|
||||
documentReference: string;
|
||||
status: ProformaStatus;
|
||||
statusLabel: string;
|
||||
isArchived: boolean;
|
||||
archivedLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailDocumentViewModel {
|
||||
title: string;
|
||||
description: string;
|
||||
src: string | null;
|
||||
mode: "pdf";
|
||||
capabilities: {
|
||||
canOpen: boolean;
|
||||
canDownload: boolean;
|
||||
canPrint: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProformaDetailDocumentState {
|
||||
isInitialLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailSummaryViewModel {
|
||||
totalLabel: string;
|
||||
documentReference: string;
|
||||
statusLabel: string;
|
||||
currencyCode: string;
|
||||
lineCount: number;
|
||||
totalUnitsLabel: string | null;
|
||||
globalDiscountLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailCustomerViewModel {
|
||||
customerId: string;
|
||||
name: string;
|
||||
tin: string | null;
|
||||
addressLines: string[];
|
||||
canOpenCustomer: boolean;
|
||||
}
|
||||
|
||||
export interface ProformaDetailDatesViewModel {
|
||||
proformaDateLabel: string;
|
||||
operationDateLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailCommercialViewModel {
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
languageLabel: string;
|
||||
internalReferenceLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailFiscalBreakdownItemViewModel {
|
||||
key: string;
|
||||
taxLabel: string;
|
||||
taxableAmountLabel: string;
|
||||
taxAmountLabel: string;
|
||||
recLabel: string | null;
|
||||
recAmountLabel: string | null;
|
||||
retentionLabel: string | null;
|
||||
retentionAmountLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ProformaDetailFiscalViewModel {
|
||||
taxRegimeLabel: string | null;
|
||||
taxModeLabel: string;
|
||||
subtotalLabel: string;
|
||||
discountLabel: string;
|
||||
taxableAmountLabel: string;
|
||||
taxAmountLabel: string;
|
||||
recAmountLabel: string | null;
|
||||
retentionAmountLabel: string | null;
|
||||
totalAmountLabel: string;
|
||||
breakdown: ProformaDetailFiscalBreakdownItemViewModel[];
|
||||
usesRec: boolean;
|
||||
usesRetention: boolean;
|
||||
}
|
||||
|
||||
export interface ProformaDetailContentViewModel {
|
||||
description: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type ProformaDetailLinkedInvoiceState =
|
||||
| "not-issued"
|
||||
| "ready-to-issue"
|
||||
| "issued-without-link"
|
||||
| "issued-linked-without-detail-route";
|
||||
|
||||
export interface ProformaDetailLinkedInvoiceViewModel {
|
||||
state: ProformaDetailLinkedInvoiceState;
|
||||
linkedInvoiceId: string | null;
|
||||
primaryText: string;
|
||||
secondaryText: string | null;
|
||||
canOpenLinkedInvoice: false;
|
||||
}
|
||||
|
||||
export interface ProformaDetailGeneralViewModel {
|
||||
summary: ProformaDetailSummaryViewModel;
|
||||
customer: ProformaDetailCustomerViewModel;
|
||||
dates: ProformaDetailDatesViewModel;
|
||||
commercial: ProformaDetailCommercialViewModel;
|
||||
fiscal: ProformaDetailFiscalViewModel;
|
||||
content: ProformaDetailContentViewModel;
|
||||
linkedInvoice: ProformaDetailLinkedInvoiceViewModel;
|
||||
}
|
||||
|
||||
export type ProformaDetailActionKey =
|
||||
| "edit"
|
||||
| "change-status"
|
||||
| "issue"
|
||||
| "duplicate"
|
||||
| "download-pdf"
|
||||
| "archive"
|
||||
| "unarchive";
|
||||
|
||||
export type ProformaDetailActionPlacement = "primary" | "secondary" | "menu";
|
||||
|
||||
export interface ProformaDetailActionViewModel {
|
||||
key: ProformaDetailActionKey;
|
||||
label: string;
|
||||
visibility: "visible" | "hidden";
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
disabledReason: string | null;
|
||||
placement: ProformaDetailActionPlacement;
|
||||
}
|
||||
|
||||
export interface ProformaDetailActionsViewModel {
|
||||
items: ProformaDetailActionViewModel[];
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./controllers";
|
||||
export * from "./entities";
|
||||
export * from "./ui";
|
||||
export * from "./utils";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ProformaDetailContentProps = {
|
||||
documentPane: ReactNode;
|
||||
inspector: ReactNode;
|
||||
};
|
||||
|
||||
export const ProformaDetailContent = ({ documentPane, inspector }: ProformaDetailContentProps) => {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto lg:overflow-hidden ">
|
||||
<div className="grid min-h-full grid-cols-1 lg:h-full lg:min-h-0 lg:overflow-hidden lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
{documentPane}
|
||||
{inspector}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,57 +1,124 @@
|
||||
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@repo/shadcn-ui/components";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import { ProformaStatusBadge } from "../../../list/ui/components";
|
||||
import { ProformaPageHeader } from "../../../shared";
|
||||
import type {
|
||||
ProformaDetailActionKey,
|
||||
ProformaDetailActionsViewModel,
|
||||
ProformaDetailHeaderViewModel,
|
||||
} from "../../entities";
|
||||
import { ProformaDetailArchivedBadge, ProformaDetailHeaderMeta } from "../components";
|
||||
|
||||
import { ProformaDetailHeaderActions } from "./proforma-detail-header-actions";
|
||||
|
||||
type ProformaDetailHeaderProps = {
|
||||
header: ProformaDetailHeaderViewModel;
|
||||
actions: ProformaDetailActionsViewModel;
|
||||
onBack: () => void;
|
||||
onExecuteAction: (action: ProformaDetailActionKey) => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailHeader = ({
|
||||
header,
|
||||
actions,
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
}: ProformaDetailHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaPageHeader
|
||||
actions={<ProformaDetailHeaderActions actions={actions} onExecuteAction={onExecuteAction} />}
|
||||
backLabel={t("pages.proformas.create.back_to_list")}
|
||||
onBack={onBack}
|
||||
title={header.title}
|
||||
>
|
||||
<div className="border-b bg-background px-4 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ProformaStatusBadge className="text-sm" status={header.status} />
|
||||
{header.archivedLabel ? (
|
||||
<ProformaDetailArchivedBadge label={header.archivedLabel} />
|
||||
) : null}
|
||||
<ProformaDetailHeaderMeta
|
||||
customerName={header.customerName}
|
||||
documentReference={header.documentReference}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProformaPageHeader>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
import { ScrollArea, Separator } from "@repo/shadcn-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||
|
||||
import { ProformaGeneralPanel } from "./proforma-general-panel";
|
||||
|
||||
type ProformaDetailInspectorProps = {
|
||||
general: ProformaDetailGeneralViewModel;
|
||||
onOpenCustomer: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailInspector = ({
|
||||
general,
|
||||
onOpenCustomer,
|
||||
}: ProformaDetailInspectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t("proformas.detail.regions.details")}
|
||||
className="overflow-visible bg-background lg:min-h-0 lg:overflow-hidden lg:border-l"
|
||||
>
|
||||
<ScrollArea className="h-auto lg:h-full">
|
||||
<div className="space-y-4 p-4 sm:p-6">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight text-foreground">
|
||||
{t("proformas.detail.regions.details")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("pages.proformas.view.description")}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ProformaGeneralPanel general={general} onOpenCustomer={onOpenCustomer} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
import { PDFViewer } from "@repo/rdx-ui/components";
|
||||
|
||||
type ProformaDetailPreviewCardProps = {
|
||||
src: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onOpen: () => void;
|
||||
onDownload: () => void;
|
||||
onPrint: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailPreviewCard = ({
|
||||
src,
|
||||
isLoading,
|
||||
error,
|
||||
onOpen,
|
||||
onDownload,
|
||||
onPrint,
|
||||
}: ProformaDetailPreviewCardProps) => {
|
||||
return (
|
||||
<PDFViewer
|
||||
className="h-[calc(100vh-11.5rem)] min-h-[720px]"
|
||||
defaultZoom="fit"
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
onDownload={onDownload}
|
||||
onOpen={onOpen}
|
||||
onPrint={onPrint}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
import type { Proforma } from "../../../shared";
|
||||
import { ProformaDetailStatusInspectorCard } from "../components";
|
||||
|
||||
import { ProformaDetailSummaryCard } from "./proforma-detail-summary-card";
|
||||
|
||||
type ProformaDetailSidebarProps = {
|
||||
proforma: Proforma;
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailSidebar = ({
|
||||
proforma,
|
||||
paymentMethodLabel,
|
||||
paymentTermLabel,
|
||||
}: ProformaDetailSidebarProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProformaDetailStatusInspectorCard proforma={proforma} />
|
||||
<ProformaDetailSummaryCard
|
||||
paymentMethodLabel={paymentMethodLabel}
|
||||
paymentTermLabel={paymentTermLabel}
|
||||
proforma={proforma}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,159 +0,0 @@
|
||||
import { FieldValueList, FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
|
||||
import { Separator } from "@repo/shadcn-ui/components";
|
||||
import { LandmarkIcon } from "lucide-react";
|
||||
|
||||
import { type Proforma } from "../../../shared";
|
||||
import { formatProformaDate, formatProformaMoney } from "../components/proforma-detail-formatters";
|
||||
|
||||
type ProformaDetailSummaryCardProps = {
|
||||
proforma: Proforma;
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
};
|
||||
|
||||
const SectionHeading = ({ title, description }: { title: string; description?: string }) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs leading-5 text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProformaDetailSummaryCard = ({
|
||||
proforma,
|
||||
paymentMethodLabel,
|
||||
paymentTermLabel,
|
||||
}: ProformaDetailSummaryCardProps) => {
|
||||
const billingItems = [
|
||||
{
|
||||
label: "Método de pago",
|
||||
value: paymentMethodLabel ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Plazo",
|
||||
value: paymentTermLabel ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Moneda",
|
||||
value: proforma.currencyCode,
|
||||
},
|
||||
];
|
||||
|
||||
const datesItems = [
|
||||
{
|
||||
label: "Fecha proforma",
|
||||
value: formatProformaDate(proforma.proformaDate),
|
||||
},
|
||||
{
|
||||
label: "Fecha operación",
|
||||
value: formatProformaDate(proforma.operationDate),
|
||||
},
|
||||
{
|
||||
label: "Serie",
|
||||
value: proforma.series ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Referencia",
|
||||
value: proforma.reference ?? "\u2014",
|
||||
},
|
||||
];
|
||||
|
||||
const totalsItems = [
|
||||
{
|
||||
label: "Subtotal",
|
||||
value: formatProformaMoney(proforma.subtotalAmount, proforma.currencyCode, proforma.languageCode),
|
||||
},
|
||||
{
|
||||
label: "Descuentos",
|
||||
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
|
||||
valueClassName: "text-red-600",
|
||||
},
|
||||
{
|
||||
label: "IVA",
|
||||
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
|
||||
},
|
||||
{
|
||||
label: "Total",
|
||||
value: formatProformaMoney(proforma.totalAmount, proforma.currencyCode, proforma.languageCode),
|
||||
valueClassName: "text-lg font-semibold text-foreground",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FormSectionCard
|
||||
className="shadow-sm"
|
||||
contentClassName="space-y-5"
|
||||
description="Resumen operativo de la proforma con las condiciones clave y sus importes."
|
||||
icon={<LandmarkIcon className="size-5" />}
|
||||
title="Resumen de proforma"
|
||||
>
|
||||
<FormSectionGrid className="gap-y-6 md:grid-cols-1">
|
||||
<div className="space-y-3 md:col-span-12">
|
||||
<SectionHeading
|
||||
description="Condiciones de cobro configuradas actualmente para esta proforma."
|
||||
title="Cobros"
|
||||
/>
|
||||
<FieldValueList
|
||||
className="rounded-lg border bg-muted/20 p-4"
|
||||
items={billingItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-12">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:col-span-12">
|
||||
<SectionHeading
|
||||
description="Fechas principales y referencias internas del documento."
|
||||
title="Fechas y referencias"
|
||||
/>
|
||||
<FieldValueList
|
||||
className="rounded-lg border bg-muted/20 p-4"
|
||||
items={datesItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-12">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:col-span-12">
|
||||
<SectionHeading
|
||||
description="Texto libre que acompaña a la proforma y puede trasladarse al PDF."
|
||||
title="Notas"
|
||||
/>
|
||||
<div className="rounded-lg border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">Descripción</p>
|
||||
<p>{proforma.description ?? "Sin descripción."}</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">Observaciones</p>
|
||||
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-12">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:col-span-12">
|
||||
<SectionHeading
|
||||
description="Desglose económico principal del documento."
|
||||
title="Resumen económico"
|
||||
/>
|
||||
<FieldValueList
|
||||
className="rounded-lg border bg-muted/20 p-4"
|
||||
itemClassName="items-center"
|
||||
items={totalsItems}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</FormSectionGrid>
|
||||
</FormSectionCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import { PDFViewer } from "@repo/rdx-ui/components";
|
||||
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { useId } from "react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities";
|
||||
|
||||
type ProformaDocumentPaneProps = {
|
||||
document: ProformaDetailDocumentViewModel;
|
||||
state: ProformaDetailDocumentState;
|
||||
onDownload: () => void;
|
||||
onOpen: () => void;
|
||||
onPrint: () => void;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDocumentPane = ({
|
||||
document,
|
||||
state,
|
||||
onDownload,
|
||||
onOpen,
|
||||
onPrint,
|
||||
onRetry,
|
||||
}: ProformaDocumentPaneProps) => {
|
||||
const { t } = useTranslation();
|
||||
const headingId = useId();
|
||||
const isRefreshing = state.isFetching && Boolean(document.src);
|
||||
const shouldRenderBlockingError = state.isError && !document.src;
|
||||
const shouldRenderStaleError = state.isError && Boolean(document.src);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className="min-w-0 overflow-visible border-b bg-muted/30 lg:min-h-0 lg:overflow-hidden lg:border-b-0"
|
||||
>
|
||||
<div className="h-full min-h-0 p-4 sm:p-6">
|
||||
<h2 className="sr-only" id={headingId}>
|
||||
{document.title}
|
||||
</h2>
|
||||
|
||||
{shouldRenderStaleError ? (
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>{t("proformas.detail.document.error_title")}</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>{state.errorMessage ?? t("proformas.detail.document.error")}</p>
|
||||
{onRetry ? (
|
||||
<Button onClick={onRetry} type="button" variant="outline">
|
||||
{t("proformas.detail.document.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<PDFViewer
|
||||
className={cn(
|
||||
"min-h-[32rem] overflow-hidden border-border/80 bg-background shadow-none lg:h-full lg:min-h-full"
|
||||
)}
|
||||
defaultZoom="fit"
|
||||
description={document.description}
|
||||
error={shouldRenderBlockingError ? state.errorMessage : null}
|
||||
isRefreshing={isRefreshing}
|
||||
labels={{
|
||||
download: t("proformas.detail.document.actions.download"),
|
||||
emptyDescription: t("proformas.detail.document.empty_description"),
|
||||
emptyTitle: t("proformas.detail.document.empty_title"),
|
||||
errorTitle: t("proformas.detail.document.error_title"),
|
||||
fit: t("proformas.detail.document.actions.fit"),
|
||||
loading: t("proformas.detail.document.loading"),
|
||||
open: t("proformas.detail.document.actions.open"),
|
||||
print: t("proformas.detail.document.actions.print"),
|
||||
refreshing: t("proformas.detail.document.refreshing"),
|
||||
retry: t("proformas.detail.document.retry"),
|
||||
zoomIn: t("proformas.detail.document.actions.zoom_in"),
|
||||
zoomOut: t("proformas.detail.document.actions.zoom_out"),
|
||||
zoomSelect: t("proformas.detail.document.actions.zoom_select"),
|
||||
}}
|
||||
loading={state.isInitialLoading}
|
||||
onDownload={onDownload}
|
||||
onOpen={onOpen}
|
||||
onPrint={onPrint}
|
||||
onRetry={onRetry}
|
||||
src={document.src}
|
||||
title={document.title}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import type { ProformaDetailGeneralViewModel } from "../../entities";
|
||||
import {
|
||||
ProformaCommercialSection,
|
||||
ProformaCustomerSection,
|
||||
ProformaFiscalSection,
|
||||
ProformaLinkedInvoiceSection,
|
||||
ProformaSummarySection,
|
||||
} from "../sections";
|
||||
|
||||
export interface ProformaGeneralPanelProps {
|
||||
general: ProformaDetailGeneralViewModel;
|
||||
onOpenCustomer: () => void;
|
||||
}
|
||||
|
||||
export const ProformaGeneralPanel = ({
|
||||
general,
|
||||
onOpenCustomer,
|
||||
}: ProformaGeneralPanelProps) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProformaSummarySection
|
||||
content={general.content}
|
||||
dates={general.dates}
|
||||
summary={general.summary}
|
||||
/>
|
||||
<ProformaCustomerSection customer={general.customer} onOpenCustomer={onOpenCustomer} />
|
||||
<ProformaCommercialSection commercial={general.commercial} />
|
||||
<ProformaFiscalSection fiscal={general.fiscal} />
|
||||
<ProformaLinkedInvoiceSection linkedInvoice={general.linkedInvoice} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,5 @@
|
||||
export * from "./proforma-detail-archived-badge";
|
||||
export * from "./proforma-detail-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";
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { Proforma } from "../../../shared";
|
||||
|
||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
||||
|
||||
type ProformaDetailBillingInspectorCardProps = {
|
||||
proforma: Proforma;
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailBillingInspectorCard = ({
|
||||
proforma,
|
||||
paymentMethodLabel,
|
||||
paymentTermLabel,
|
||||
onEdit,
|
||||
}: ProformaDetailBillingInspectorCardProps) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Método de pago",
|
||||
value: paymentMethodLabel ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Plazo",
|
||||
value: paymentTermLabel ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Moneda",
|
||||
value: proforma.currencyCode,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Cobro">
|
||||
<FieldValueList items={items} />
|
||||
</ProformaDetailInspectorCard>
|
||||
);
|
||||
};
|
||||
@ -1,41 +0,0 @@
|
||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { Proforma } from "../../../shared";
|
||||
|
||||
import { formatProformaDate } from "./proforma-detail-formatters";
|
||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
||||
|
||||
type ProformaDetailDatesInspectorCardProps = {
|
||||
proforma: Proforma;
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailDatesInspectorCard = ({
|
||||
proforma,
|
||||
onEdit,
|
||||
}: ProformaDetailDatesInspectorCardProps) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Fecha proforma",
|
||||
value: formatProformaDate(proforma.proformaDate),
|
||||
},
|
||||
{
|
||||
label: "Fecha operación",
|
||||
value: formatProformaDate(proforma.operationDate),
|
||||
},
|
||||
{
|
||||
label: "Serie de la factura",
|
||||
value: proforma.series ?? "\u2014",
|
||||
},
|
||||
{
|
||||
label: "Referencia interna",
|
||||
value: proforma.reference ?? "\u2014",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Fechas y referencia">
|
||||
<FieldValueList items={items} />
|
||||
</ProformaDetailInspectorCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ProformaDetailFieldProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
export const ProformaDetailField = ({
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
}: ProformaDetailFieldProps) => {
|
||||
return (
|
||||
<div className="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start sm:gap-4">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground sm:text-right sm:text-balance",
|
||||
valueClassName
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,25 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import 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}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
||||
import { SquarePenIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ProformaDetailInspectorCardProps = {
|
||||
title: string;
|
||||
onEdit?: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ProformaDetailInspectorCard = ({
|
||||
title,
|
||||
onEdit,
|
||||
children,
|
||||
}: ProformaDetailInspectorCardProps) => {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
{onEdit ? (
|
||||
<Button onClick={onEdit} size="icon-sm" type="button" variant="ghost">
|
||||
<SquarePenIcon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -1,31 +0,0 @@
|
||||
import { Separator } from "@repo/shadcn-ui/components";
|
||||
|
||||
import type { Proforma } from "../../../shared";
|
||||
|
||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
||||
|
||||
type ProformaDetailNotesInspectorCardProps = {
|
||||
proforma: Proforma;
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export const ProformaDetailNotesInspectorCard = ({
|
||||
proforma,
|
||||
onEdit,
|
||||
}: ProformaDetailNotesInspectorCardProps) => {
|
||||
return (
|
||||
<ProformaDetailInspectorCard onEdit={onEdit} title="Notas">
|
||||
<div className="space-y-4 text-sm leading-6 text-muted-foreground">
|
||||
<div>
|
||||
<p className="mb-1 font-medium text-foreground">Descripción</p>
|
||||
<p>{proforma.description ?? "Sin descripción."}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 font-medium text-foreground">Observaciones</p>
|
||||
<p>{proforma.notes ?? "Sin notas adicionales."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProformaDetailInspectorCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { Separator } from "@repo/shadcn-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface ProformaDetailSectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProformaDetailSection = ({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
}: ProformaDetailSectionProps) => {
|
||||
const headingId = useId();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground" id={headingId}>
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="text-sm leading-5 text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">{children}</div>
|
||||
<Separator />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,30 +1,44 @@
|
||||
import { Card, CardContent, Skeleton } from "@repo/shadcn-ui/components";
|
||||
import { Skeleton } from "@repo/shadcn-ui/components";
|
||||
|
||||
export const ProformaDetailSkeleton = () => {
|
||||
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>
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
|
||||
import type { Proforma } from "../../../shared";
|
||||
|
||||
const getStatusTone = (status: Proforma["status"]) => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-900";
|
||||
case "sent":
|
||||
return "border-amber-200 bg-amber-50 text-amber-900";
|
||||
case "issued":
|
||||
return "border-sky-200 bg-sky-50 text-sky-900";
|
||||
case "rejected":
|
||||
return "border-red-200 bg-red-50 text-red-900";
|
||||
default:
|
||||
return "border-slate-200 bg-slate-50 text-slate-900";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusSummary = (proforma: Proforma) => {
|
||||
if (proforma.isArchived) {
|
||||
return {
|
||||
title: "Proforma archivada",
|
||||
description: "Está fuera del flujo principal y puedes recuperarla cuando lo necesites.",
|
||||
};
|
||||
}
|
||||
|
||||
switch (proforma.status) {
|
||||
case "approved":
|
||||
return {
|
||||
title: "Lista para convertirse en factura",
|
||||
description: "La proforma ya está aprobada por el cliente y puede emitirse como factura.",
|
||||
};
|
||||
case "sent":
|
||||
return {
|
||||
title: "Pendiente de respuesta del cliente",
|
||||
description:
|
||||
"Se ha enviado al cliente y está a la espera de aceptación o rechazo por parte del cliente.",
|
||||
};
|
||||
case "issued":
|
||||
return {
|
||||
title: "Convertida en factura",
|
||||
description: "La proforma ya ha sido emitida y forma parte del flujo fiscal.",
|
||||
};
|
||||
case "rejected":
|
||||
return {
|
||||
title: "Requiere revisión",
|
||||
description: "El cliente la ha rechazado y conviene revisar condiciones o importes.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Pendiente de revisión",
|
||||
description: "Sigue en borrador y todavía puede modificarse antes de enviarla al cliente.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type ProformaDetailStatusInspectorCardProps = {
|
||||
proforma: Proforma;
|
||||
};
|
||||
|
||||
export const ProformaDetailStatusInspectorCard = ({
|
||||
proforma,
|
||||
}: ProformaDetailStatusInspectorCardProps) => {
|
||||
const summary = getStatusSummary(proforma);
|
||||
|
||||
return (
|
||||
<Alert className={cn("shadow-sm", getStatusTone(proforma.status))}>
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
<AlertTitle>{summary.title}</AlertTitle>
|
||||
<AlertDescription>{summary.description}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import { FieldValueList } from "@repo/rdx-ui/components";
|
||||
|
||||
import type { Proforma } from "../../../shared";
|
||||
|
||||
import { formatProformaMoney } from "./proforma-detail-formatters";
|
||||
import { ProformaDetailInspectorCard } from "./proforma-detail-inspector-card";
|
||||
|
||||
type ProformaDetailTotalsInspectorCardProps = {
|
||||
proforma: Proforma;
|
||||
};
|
||||
|
||||
export const ProformaDetailTotalsInspectorCard = ({
|
||||
proforma,
|
||||
}: ProformaDetailTotalsInspectorCardProps) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Subtotal",
|
||||
value: formatProformaMoney(
|
||||
proforma.subtotalAmount,
|
||||
proforma.currencyCode,
|
||||
proforma.languageCode
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Descuentos",
|
||||
value: `-${formatProformaMoney(proforma.totalDiscountAmount, proforma.currencyCode, proforma.languageCode)}`,
|
||||
valueClassName: "text-red-600",
|
||||
},
|
||||
{
|
||||
label: "IVA",
|
||||
value: formatProformaMoney(proforma.ivaAmount, proforma.currencyCode, proforma.languageCode),
|
||||
},
|
||||
{
|
||||
label: "Total",
|
||||
value: formatProformaMoney(
|
||||
proforma.totalAmount,
|
||||
proforma.currencyCode,
|
||||
proforma.languageCode
|
||||
),
|
||||
valueClassName: "text-lg font-semibold text-foreground",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProformaDetailInspectorCard title="Resumen económico">
|
||||
<FieldValueList itemClassName="items-center" items={items} valueClassName="tabular-nums" />
|
||||
</ProformaDetailInspectorCard>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./blocks";
|
||||
export * from "./components";
|
||||
export * from "./pages";
|
||||
export * from "./sections";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export * from "./proforma-commercial-section";
|
||||
export * from "./proforma-content-section";
|
||||
export * from "./proforma-customer-section";
|
||||
export * from "./proforma-dates-section";
|
||||
export * from "./proforma-fiscal-section";
|
||||
export * from "./proforma-linked-invoice-section";
|
||||
export * from "./proforma-summary-section";
|
||||
@ -0,0 +1,35 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailCommercialViewModel } from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaCommercialSectionProps = {
|
||||
commercial: ProformaDetailCommercialViewModel;
|
||||
};
|
||||
|
||||
export const ProformaCommercialSection = ({
|
||||
commercial,
|
||||
}: ProformaCommercialSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.commercial")}
|
||||
title={t("proformas.detail.sections.commercial")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.payment_method")}
|
||||
value={commercial.paymentMethodLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.payment_term")}
|
||||
value={commercial.paymentTermLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.internal_reference")}
|
||||
value={commercial.internalReferenceLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
</div>
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailContentViewModel } from "../../entities";
|
||||
import { ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaContentSectionProps = {
|
||||
content: ProformaDetailContentViewModel;
|
||||
};
|
||||
|
||||
export const ProformaContentSection = ({
|
||||
content,
|
||||
}: ProformaContentSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!content.description && !content.notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.content")}
|
||||
title={t("proformas.detail.sections.content")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{content.description ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("proformas.detail.fields.description")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{content.description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{content.notes ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("proformas.detail.fields.notes")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{content.notes}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailCustomerViewModel } from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaCustomerSectionProps = {
|
||||
customer: ProformaDetailCustomerViewModel;
|
||||
onOpenCustomer: () => void;
|
||||
};
|
||||
|
||||
export const ProformaCustomerSection = ({
|
||||
customer,
|
||||
onOpenCustomer,
|
||||
}: ProformaCustomerSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
action={
|
||||
<Button
|
||||
disabled={!customer.canOpenCustomer}
|
||||
onClick={onOpenCustomer}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("proformas.detail.actions.open_customer")}
|
||||
</Button>
|
||||
}
|
||||
description={t("proformas.detail.section_descriptions.customer")}
|
||||
title={t("proformas.detail.sections.customer")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProformaDetailField label={t("form_groups.proformas.customer.title")} value={customer.name} />
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.customer_tin")}
|
||||
value={customer.tin ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.address")}
|
||||
value={
|
||||
customer.addressLines.length > 0 ? (
|
||||
<div className="space-y-1 whitespace-pre-wrap">
|
||||
{customer.addressLines.map((line) => (
|
||||
<p key={line}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
t("proformas.detail.empty.no_address")
|
||||
)
|
||||
}
|
||||
valueClassName="sm:text-left"
|
||||
/>
|
||||
</div>
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailDatesViewModel } from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaDatesSectionProps = {
|
||||
dates: ProformaDetailDatesViewModel;
|
||||
};
|
||||
|
||||
export const ProformaDatesSection = ({ dates }: ProformaDatesSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.dates")}
|
||||
title={t("proformas.detail.sections.dates")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.proforma_date")}
|
||||
value={dates.proformaDateLabel}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.operation_date")}
|
||||
value={dates.operationDateLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
</div>
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailFiscalViewModel } from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaFiscalSectionProps = {
|
||||
fiscal: ProformaDetailFiscalViewModel;
|
||||
};
|
||||
|
||||
export const ProformaFiscalSection = ({ fiscal }: ProformaFiscalSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.fiscal")}
|
||||
title={t("proformas.detail.sections.fiscal")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.tax_regime")}
|
||||
value={fiscal.taxRegimeLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.tax_mode")}
|
||||
value={fiscal.taxModeLabel}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.subtotal")}
|
||||
value={fiscal.subtotalLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.discount")}
|
||||
value={fiscal.discountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.taxable_base")}
|
||||
value={fiscal.taxableAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.tax")}
|
||||
value={fiscal.taxAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
{fiscal.usesRec ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.rec")}
|
||||
value={fiscal.recAmountLabel ?? t("proformas.detail.empty.not_available")}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
) : null}
|
||||
{fiscal.usesRetention ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.retention")}
|
||||
value={fiscal.retentionAmountLabel ?? t("proformas.detail.empty.not_available")}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
) : null}
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.total")}
|
||||
value={fiscal.totalAmountLabel}
|
||||
valueClassName="tabular-nums text-base font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fiscal.breakdown.length > 0 ? (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/10 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("proformas.detail.fields.tax_breakdown")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{fiscal.breakdown.map((item) => (
|
||||
<div className="space-y-2 border-b pb-4 last:border-b-0 last:pb-0" key={item.key}>
|
||||
<p className="text-sm font-medium text-foreground">{item.taxLabel}</p>
|
||||
<div className="space-y-2">
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.taxable_base")}
|
||||
value={item.taxableAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.tax")}
|
||||
value={item.taxAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
{item.recLabel && item.recAmountLabel ? (
|
||||
<ProformaDetailField
|
||||
label={item.recLabel}
|
||||
value={item.recAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
) : null}
|
||||
{item.retentionLabel && item.retentionAmountLabel ? (
|
||||
<ProformaDetailField
|
||||
label={item.retentionLabel}
|
||||
value={item.retentionAmountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,53 @@
|
||||
import { Badge } from "@repo/shadcn-ui/components";
|
||||
import { CircleAlertIcon, FileClockIcon, FileOutputIcon, Link2OffIcon } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type { ProformaDetailLinkedInvoiceViewModel } from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaLinkedInvoiceSectionProps = {
|
||||
linkedInvoice: ProformaDetailLinkedInvoiceViewModel;
|
||||
};
|
||||
|
||||
const STATE_ICON = {
|
||||
"not-issued": FileClockIcon,
|
||||
"ready-to-issue": FileOutputIcon,
|
||||
"issued-without-link": CircleAlertIcon,
|
||||
"issued-linked-without-detail-route": Link2OffIcon,
|
||||
} as const;
|
||||
|
||||
export const ProformaLinkedInvoiceSection = ({
|
||||
linkedInvoice,
|
||||
}: ProformaLinkedInvoiceSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = STATE_ICON[linkedInvoice.state];
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.linked_invoice")}
|
||||
title={t("proformas.detail.sections.linked_invoice")}
|
||||
>
|
||||
<div className="rounded-lg border bg-muted/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 text-muted-foreground">
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<Badge variant="outline">{linkedInvoice.primaryText}</Badge>
|
||||
{linkedInvoice.secondaryText ? (
|
||||
<p className="text-sm leading-6 text-muted-foreground">{linkedInvoice.secondaryText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkedInvoice.state === "issued-linked-without-detail-route" && linkedInvoice.linkedInvoiceId ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.linked_invoice_id")}
|
||||
value={linkedInvoice.linkedInvoiceId}
|
||||
valueClassName="break-all sm:text-left"
|
||||
/>
|
||||
) : null}
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { useTranslation } from "../../../../i18n";
|
||||
import type {
|
||||
ProformaDetailContentViewModel,
|
||||
ProformaDetailDatesViewModel,
|
||||
ProformaDetailSummaryViewModel,
|
||||
} from "../../entities";
|
||||
import { ProformaDetailField, ProformaDetailSection } from "../components";
|
||||
|
||||
type ProformaSummarySectionProps = {
|
||||
summary: ProformaDetailSummaryViewModel;
|
||||
dates: ProformaDetailDatesViewModel;
|
||||
content: ProformaDetailContentViewModel;
|
||||
};
|
||||
|
||||
export const ProformaSummarySection = ({
|
||||
summary,
|
||||
dates,
|
||||
content,
|
||||
}: ProformaSummarySectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProformaDetailSection
|
||||
description={t("proformas.detail.section_descriptions.summary")}
|
||||
title={t("proformas.detail.sections.summary")}
|
||||
>
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-4">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("proformas.detail.fields.total")}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||
{summary.totalLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.reference")}
|
||||
value={summary.documentReference}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.status")}
|
||||
value={summary.statusLabel}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.proforma_date")}
|
||||
value={dates.proformaDateLabel}
|
||||
/>
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.operation_date")}
|
||||
value={dates.operationDateLabel ?? t("proformas.detail.empty.not_available")}
|
||||
/>
|
||||
{content.description ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.description")}
|
||||
value={content.description}
|
||||
valueClassName="whitespace-pre-wrap sm:text-left"
|
||||
/>
|
||||
) : null}
|
||||
{content.notes ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.notes")}
|
||||
value={content.notes}
|
||||
valueClassName="whitespace-pre-wrap sm:text-left"
|
||||
/>
|
||||
) : null}
|
||||
{summary.globalDiscountLabel ? (
|
||||
<ProformaDetailField
|
||||
label={t("proformas.detail.fields.global_discount")}
|
||||
value={summary.globalDiscountLabel}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ProformaDetailSection>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,166 @@
|
||||
import type { ProformaStatus } from "../../shared";
|
||||
import { PROFORMA_STATUS_TRANSITIONS } from "../../shared";
|
||||
import type {
|
||||
ProformaDetailActionKey,
|
||||
ProformaDetailActionViewModel,
|
||||
ProformaDetailActionsViewModel,
|
||||
} from "../entities";
|
||||
|
||||
export interface ProformaDetailActionLabels {
|
||||
edit: string;
|
||||
changeStatus: string;
|
||||
issue: string;
|
||||
duplicate: string;
|
||||
downloadPdf: string;
|
||||
archive: string;
|
||||
unarchive: string;
|
||||
disabledReasons: {
|
||||
archivedOnly: string;
|
||||
archiveConflict: string;
|
||||
editIssued: string;
|
||||
editArchived: string;
|
||||
statusIssued: string;
|
||||
statusArchived: string;
|
||||
issueNotAllowed: string;
|
||||
operationInProgress: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildProformaDetailActionsParams {
|
||||
status: ProformaStatus;
|
||||
isArchived: boolean;
|
||||
labels: ProformaDetailActionLabels;
|
||||
loading: Partial<Record<ProformaDetailActionKey, boolean>>;
|
||||
}
|
||||
|
||||
const HIDDEN_ACTION: Omit<ProformaDetailActionViewModel, "key" | "label" | "placement"> = {
|
||||
visibility: "hidden",
|
||||
enabled: false,
|
||||
loading: false,
|
||||
disabledReason: null,
|
||||
};
|
||||
|
||||
const hasManualTransition = (status: ProformaStatus) => {
|
||||
return (PROFORMA_STATUS_TRANSITIONS[status] ?? []).length > 0;
|
||||
};
|
||||
|
||||
const buildAction = (
|
||||
key: ProformaDetailActionKey,
|
||||
label: string,
|
||||
placement: ProformaDetailActionViewModel["placement"],
|
||||
visibility: ProformaDetailActionViewModel["visibility"],
|
||||
enabled: boolean,
|
||||
loading: boolean,
|
||||
disabledReason: string | null
|
||||
): ProformaDetailActionViewModel => ({
|
||||
key,
|
||||
label,
|
||||
placement,
|
||||
visibility,
|
||||
enabled,
|
||||
loading,
|
||||
disabledReason,
|
||||
});
|
||||
|
||||
export const buildProformaDetailActions = ({
|
||||
status,
|
||||
isArchived,
|
||||
labels,
|
||||
loading,
|
||||
}: BuildProformaDetailActionsParams): ProformaDetailActionsViewModel => {
|
||||
const isDraft = status === "draft";
|
||||
const isSent = status === "sent";
|
||||
const isApproved = status === "approved";
|
||||
const isRejected = status === "rejected";
|
||||
const isIssued = status === "issued";
|
||||
const canChangeStatus = hasManualTransition(status);
|
||||
|
||||
const items: ProformaDetailActionViewModel[] = [
|
||||
buildAction(
|
||||
"edit",
|
||||
labels.edit,
|
||||
isApproved ? "secondary" : "primary",
|
||||
"visible",
|
||||
!(isArchived || isIssued),
|
||||
loading.edit ?? false,
|
||||
isIssued
|
||||
? labels.disabledReasons.editIssued
|
||||
: isArchived
|
||||
? labels.disabledReasons.editArchived
|
||||
: null
|
||||
),
|
||||
buildAction(
|
||||
"change-status",
|
||||
labels.changeStatus,
|
||||
"menu",
|
||||
isIssued ? "hidden" : "visible",
|
||||
!isArchived && canChangeStatus,
|
||||
loading["change-status"] ?? false,
|
||||
isArchived
|
||||
? labels.disabledReasons.statusArchived
|
||||
: canChangeStatus
|
||||
? null
|
||||
: labels.disabledReasons.issueNotAllowed
|
||||
),
|
||||
buildAction(
|
||||
"issue",
|
||||
labels.issue,
|
||||
"primary",
|
||||
isApproved || isSent ? "visible" : "hidden",
|
||||
isApproved && !isArchived,
|
||||
loading.issue ?? false,
|
||||
isApproved ? null : labels.disabledReasons.issueNotAllowed
|
||||
),
|
||||
buildAction(
|
||||
"duplicate",
|
||||
labels.duplicate,
|
||||
"menu",
|
||||
"visible",
|
||||
true,
|
||||
loading.duplicate ?? false,
|
||||
null
|
||||
),
|
||||
buildAction(
|
||||
"download-pdf",
|
||||
labels.downloadPdf,
|
||||
isApproved ? "secondary" : "secondary",
|
||||
"visible",
|
||||
!(loading["download-pdf"] ?? false),
|
||||
loading["download-pdf"] ?? false,
|
||||
loading["download-pdf"] ? labels.disabledReasons.operationInProgress : null
|
||||
),
|
||||
buildAction(
|
||||
"archive",
|
||||
labels.archive,
|
||||
"menu",
|
||||
isArchived || !(isDraft || isRejected) ? "hidden" : "visible",
|
||||
isDraft || isRejected,
|
||||
loading.archive ?? false,
|
||||
isDraft || isRejected ? null : labels.disabledReasons.archiveConflict
|
||||
),
|
||||
buildAction(
|
||||
"unarchive",
|
||||
labels.unarchive,
|
||||
"menu",
|
||||
isArchived ? "visible" : "hidden",
|
||||
isArchived,
|
||||
loading.unarchive ?? false,
|
||||
isArchived ? null : labels.disabledReasons.archivedOnly
|
||||
),
|
||||
];
|
||||
|
||||
const normalizedItems = items.map((item) => {
|
||||
if (item.visibility === "hidden") {
|
||||
return {
|
||||
...item,
|
||||
...HIDDEN_ACTION,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
items: normalizedItems,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,242 @@
|
||||
import { DateHelper, MoneyHelper, NumberHelper } from "@repo/rdx-utils";
|
||||
|
||||
import type { Proforma } from "../../shared";
|
||||
import type {
|
||||
ProformaDetailCommercialViewModel,
|
||||
ProformaDetailContentViewModel,
|
||||
ProformaDetailCustomerViewModel,
|
||||
ProformaDetailDatesViewModel,
|
||||
ProformaDetailDocumentViewModel,
|
||||
ProformaDetailFiscalBreakdownItemViewModel,
|
||||
ProformaDetailFiscalViewModel,
|
||||
ProformaDetailGeneralViewModel,
|
||||
ProformaDetailHeaderViewModel,
|
||||
ProformaDetailLinkedInvoiceState,
|
||||
ProformaDetailLinkedInvoiceViewModel,
|
||||
ProformaDetailSummaryViewModel,
|
||||
} from "../entities";
|
||||
|
||||
export interface ProformaDetailTranslate {
|
||||
(key: string, params?: Record<string, string | number | null | undefined>): string;
|
||||
}
|
||||
|
||||
export interface BuildProformaDetailViewModelsContext {
|
||||
translate: ProformaDetailTranslate;
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
}
|
||||
|
||||
const formatMoney = (proforma: Proforma, value: number) => {
|
||||
return MoneyHelper.formatCurrency(
|
||||
value,
|
||||
2,
|
||||
proforma.currencyCode,
|
||||
proforma.languageCode || "es-ES"
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateHelper.format(value);
|
||||
};
|
||||
|
||||
const buildLanguageLabel = (languageCode: string) => {
|
||||
try {
|
||||
return new Intl.DisplayNames([languageCode], { type: "language" }).of(languageCode) ?? languageCode;
|
||||
} catch {
|
||||
return languageCode;
|
||||
}
|
||||
};
|
||||
|
||||
const buildAddressLines = (proforma: Proforma): string[] => {
|
||||
const { recipient } = proforma;
|
||||
const firstLine = [recipient.street, recipient.street2].filter(Boolean).join(" · ");
|
||||
const secondLine = [recipient.postalCode, recipient.city, recipient.province]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const thirdLine = recipient.country ?? "";
|
||||
|
||||
return [firstLine, secondLine, thirdLine].filter(Boolean);
|
||||
};
|
||||
|
||||
const buildTotalUnitsLabel = (proforma: Proforma): string | null => {
|
||||
const valuedItems = proforma.items.filter((item) => item.isValued);
|
||||
|
||||
if (valuedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (valuedItems.some((item) => item.quantity === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalUnits = valuedItems.reduce((sum, item) => sum + (item.quantity ?? 0), 0);
|
||||
|
||||
return NumberHelper.formatNumber(totalUnits, Number.isInteger(totalUnits) ? 0 : 2);
|
||||
};
|
||||
|
||||
const buildTaxLabel = (
|
||||
item: Proforma["taxes"][number],
|
||||
translate: ProformaDetailTranslate
|
||||
): string => {
|
||||
const percentage =
|
||||
item.ivaPercentage !== null ? `${NumberHelper.formatNumber(item.ivaPercentage, 2)}%` : null;
|
||||
const code = item.ivaCode ?? translate("proformas.detail.fiscal.unknown_tax_code");
|
||||
|
||||
return percentage ? `${code} · ${percentage}` : code;
|
||||
};
|
||||
|
||||
const buildLinkedInvoiceState = (proforma: Proforma): ProformaDetailLinkedInvoiceState => {
|
||||
if (proforma.status === "approved") {
|
||||
return "ready-to-issue";
|
||||
}
|
||||
|
||||
if (proforma.status !== "issued") {
|
||||
return "not-issued";
|
||||
}
|
||||
|
||||
if (!proforma.linkedInvoiceId) {
|
||||
return "issued-without-link";
|
||||
}
|
||||
|
||||
return "issued-linked-without-detail-route";
|
||||
};
|
||||
|
||||
export const buildProformaDetailHeaderViewModel = (
|
||||
proforma: Proforma,
|
||||
context: BuildProformaDetailViewModelsContext
|
||||
): ProformaDetailHeaderViewModel => {
|
||||
return {
|
||||
title: context.translate("proformas.detail.header.title", {
|
||||
reference: proforma.proformaReference,
|
||||
}),
|
||||
customerName: proforma.recipient.name || context.translate("proformas.detail.customer.unknown"),
|
||||
documentReference: proforma.proformaReference,
|
||||
status: proforma.status,
|
||||
statusLabel: context.translate(`catalog.proformas.status.${proforma.status}.label`),
|
||||
isArchived: proforma.isArchived,
|
||||
archivedLabel: proforma.isArchived
|
||||
? context.translate("proformas.detail.header.archived_label")
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildProformaDetailDocumentViewModel = (
|
||||
src: string | null,
|
||||
context: BuildProformaDetailViewModelsContext
|
||||
): ProformaDetailDocumentViewModel => {
|
||||
return {
|
||||
title: context.translate("proformas.detail.document.title"),
|
||||
description: context.translate("proformas.detail.document.description"),
|
||||
src,
|
||||
mode: "pdf",
|
||||
capabilities: {
|
||||
canOpen: Boolean(src),
|
||||
canDownload: Boolean(src),
|
||||
canPrint: Boolean(src),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildProformaDetailGeneralViewModel = (
|
||||
proforma: Proforma,
|
||||
context: BuildProformaDetailViewModelsContext
|
||||
): ProformaDetailGeneralViewModel => {
|
||||
const linkedInvoiceState = buildLinkedInvoiceState(proforma);
|
||||
const usesRec = proforma.recAmount > 0 || proforma.taxConfig.usesEquivalenceSurcharge;
|
||||
const usesRetention = proforma.retentionAmount > 0 || proforma.taxConfig.usesRetention;
|
||||
|
||||
const breakdown: ProformaDetailFiscalBreakdownItemViewModel[] = proforma.taxes.map((tax, index) => ({
|
||||
key: `${tax.ivaCode ?? "tax"}-${index}`,
|
||||
taxLabel: buildTaxLabel(tax, context.translate),
|
||||
taxableAmountLabel: formatMoney(proforma, tax.taxableAmount),
|
||||
taxAmountLabel: formatMoney(proforma, tax.ivaAmount),
|
||||
recLabel:
|
||||
tax.recPercentage !== null
|
||||
? `${tax.recCode ?? context.translate("proformas.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.recPercentage, 2)}%`
|
||||
: null,
|
||||
recAmountLabel: tax.recPercentage !== null ? formatMoney(proforma, tax.recAmount) : null,
|
||||
retentionLabel:
|
||||
tax.retentionPercentage !== null
|
||||
? `${tax.retentionCode ?? context.translate("proformas.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.retentionPercentage, 2)}%`
|
||||
: null,
|
||||
retentionAmountLabel:
|
||||
tax.retentionPercentage !== null ? formatMoney(proforma, tax.retentionAmount) : null,
|
||||
}));
|
||||
|
||||
const linkedInvoice: ProformaDetailLinkedInvoiceViewModel = {
|
||||
state: linkedInvoiceState,
|
||||
linkedInvoiceId: proforma.linkedInvoiceId ?? null,
|
||||
primaryText: context.translate(`proformas.detail.linked_invoice.states.${linkedInvoiceState}.title`),
|
||||
secondaryText: context.translate(
|
||||
`proformas.detail.linked_invoice.states.${linkedInvoiceState}.description`
|
||||
),
|
||||
canOpenLinkedInvoice: false,
|
||||
};
|
||||
|
||||
const summary: ProformaDetailSummaryViewModel = {
|
||||
totalLabel: formatMoney(proforma, proforma.totalAmount),
|
||||
documentReference: proforma.proformaReference,
|
||||
statusLabel: context.translate(`catalog.proformas.status.${proforma.status}.label`),
|
||||
currencyCode: proforma.currencyCode,
|
||||
lineCount: proforma.items.length,
|
||||
totalUnitsLabel: buildTotalUnitsLabel(proforma),
|
||||
globalDiscountLabel:
|
||||
proforma.globalDiscountPercentage > 0
|
||||
? `${NumberHelper.formatNumber(proforma.globalDiscountPercentage, 2)}%`
|
||||
: null,
|
||||
};
|
||||
|
||||
const customer: ProformaDetailCustomerViewModel = {
|
||||
customerId: proforma.customerId,
|
||||
name: proforma.recipient.name || context.translate("proformas.detail.customer.unknown"),
|
||||
tin: proforma.recipient.tin,
|
||||
addressLines: buildAddressLines(proforma),
|
||||
canOpenCustomer: Boolean(proforma.customerId),
|
||||
};
|
||||
|
||||
const dates: ProformaDetailDatesViewModel = {
|
||||
proformaDateLabel: DateHelper.format(proforma.proformaDate),
|
||||
operationDateLabel: formatDate(proforma.operationDate),
|
||||
};
|
||||
|
||||
const commercial: ProformaDetailCommercialViewModel = {
|
||||
paymentMethodLabel: context.paymentMethodLabel,
|
||||
paymentTermLabel: context.paymentTermLabel,
|
||||
languageLabel: buildLanguageLabel(proforma.languageCode),
|
||||
internalReferenceLabel: proforma.reference,
|
||||
};
|
||||
|
||||
const fiscal: ProformaDetailFiscalViewModel = {
|
||||
taxRegimeLabel: proforma.taxRegimeCode,
|
||||
taxModeLabel: context.translate(`proformas.detail.fiscal.tax_mode.${proforma.taxConfig.taxMode}`),
|
||||
subtotalLabel: formatMoney(proforma, proforma.subtotalAmount),
|
||||
discountLabel: formatMoney(proforma, proforma.totalDiscountAmount),
|
||||
taxableAmountLabel: formatMoney(proforma, proforma.taxableAmount),
|
||||
taxAmountLabel: formatMoney(proforma, proforma.ivaAmount),
|
||||
recAmountLabel: usesRec ? formatMoney(proforma, proforma.recAmount) : null,
|
||||
retentionAmountLabel: usesRetention ? formatMoney(proforma, proforma.retentionAmount) : null,
|
||||
totalAmountLabel: formatMoney(proforma, proforma.totalAmount),
|
||||
breakdown,
|
||||
usesRec,
|
||||
usesRetention,
|
||||
};
|
||||
|
||||
const content: ProformaDetailContentViewModel = {
|
||||
description: proforma.description,
|
||||
notes: proforma.notes,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
customer,
|
||||
dates,
|
||||
commercial,
|
||||
fiscal,
|
||||
content,
|
||||
linkedInvoice,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./build-proforma-detail-actions";
|
||||
export * from "./build-proforma-detail-view-models";
|
||||
@ -1,9 +1,8 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user