feat: add proforma detail page and related components
- Exported detail components in proformas index. - Enhanced proforma grid with view action. - Implemented navigation to proforma detail page. - Created proforma detail controller for data fetching and actions. - Developed proforma detail page with various inspector cards for displaying information. - Added reusable ProformaPageHeader component for consistent header layout. - Introduced FieldValueList component for displaying key-value pairs. - Implemented PDFViewer component for previewing proforma PDFs.
This commit is contained in:
parent
451a1013d0
commit
1506a78582
@ -26,6 +26,10 @@ const ProformaUpdatePage = lazy(() =>
|
||||
import("./proformas/update").then((m) => ({ default: m.ProformaUpdatePage }))
|
||||
);
|
||||
|
||||
const ProformaDetailPage = lazy(() =>
|
||||
import("./proformas/detail").then((m) => ({ default: m.ProformaDetailPage }))
|
||||
);
|
||||
|
||||
const IssuedInvoicesLayout = lazy(() =>
|
||||
import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
|
||||
);
|
||||
@ -93,6 +97,19 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
path: "proformas/:id",
|
||||
handle: {
|
||||
layout: "app-fullscreen",
|
||||
protected: true,
|
||||
},
|
||||
element: (
|
||||
<ProformaLayout>
|
||||
<ProformaDetailPage />
|
||||
</ProformaLayout>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
path: "customer-invoices",
|
||||
handle: {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
|
||||
import { PageKeyboardShortcutsButton } from "@erp/core/components";
|
||||
import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ProformaPageHeader } from "../../../shared";
|
||||
|
||||
export interface ProformaCreateHeaderProps {
|
||||
formId: string;
|
||||
isSaving: boolean;
|
||||
@ -16,7 +18,7 @@ export const ProformaCreateHeader = ({
|
||||
children,
|
||||
}: ProformaCreateHeaderProps) => {
|
||||
return (
|
||||
<PageFormHeader
|
||||
<ProformaPageHeader
|
||||
actions={
|
||||
<FormActionsBar align="end" reverseOnMobile={false}>
|
||||
<PageKeyboardShortcutsButton
|
||||
@ -45,11 +47,10 @@ export const ProformaCreateHeader = ({
|
||||
/>
|
||||
</FormActionsBar>
|
||||
}
|
||||
backLabel="Volver"
|
||||
onBack={onCancel}
|
||||
title="Nueva proforma"
|
||||
>
|
||||
{children}
|
||||
</PageFormHeader>
|
||||
</ProformaPageHeader>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-proforma-detail-page-controller";
|
||||
@ -0,0 +1,250 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./controllers";
|
||||
export * from "./ui";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-detail-page";
|
||||
@ -0,0 +1,509 @@
|
||||
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
|
||||
import { AppContent, BackHistoryButton, FieldValueList, PDFViewer } from "@repo/rdx-ui/components";
|
||||
import { MoneyHelper } from "@repo/rdx-utils";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
DownloadIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SquarePenIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
|
||||
import { IssueProformaDialog } from "../../../issue-proforma";
|
||||
import { ProformaStatusBadge } from "../../../list/ui/components";
|
||||
import { type Proforma, type ProformaItem, ProformaPageHeader } from "../../../shared";
|
||||
import { useProformaDetailPageController } from "../../controllers";
|
||||
|
||||
const ZOOM_OPTIONS = [
|
||||
{ label: "Ajustar", value: "fit" },
|
||||
{ label: "75%", value: "75" },
|
||||
{ label: "90%", value: "90" },
|
||||
{ label: "100%", value: "100" },
|
||||
{ label: "125%", value: "125" },
|
||||
];
|
||||
|
||||
const DOCUMENT_WIDTH = 794;
|
||||
const DOCUMENT_HEIGHT = 1123;
|
||||
|
||||
const formatDate = (value: string | null | undefined) => {
|
||||
if (!value) {
|
||||
return "No informado";
|
||||
}
|
||||
|
||||
const [year, month, day] = value.split("-");
|
||||
if (!(year && month && day)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
const formatMoney = (amount: number, currencyCode: string) => {
|
||||
return MoneyHelper.formatCurrency(amount, 2, currencyCode);
|
||||
};
|
||||
|
||||
const formatQuantity = (value: number | null) => {
|
||||
if (value === null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("es-ES", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
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 y puede emitirse como factura.",
|
||||
};
|
||||
case "sent":
|
||||
return {
|
||||
title: "Pendiente de respuesta del cliente",
|
||||
description: "Se ha enviado y está a la espera de aceptación o rechazo.",
|
||||
};
|
||||
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 ajustarse antes de enviarla.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const InspectorCard = ({
|
||||
title,
|
||||
onEdit,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onEdit?: () => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusInspectorCard = ({ proforma }: { proforma: Proforma }) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingInspectorCard = ({
|
||||
proforma,
|
||||
paymentMethodLabel,
|
||||
paymentTermLabel,
|
||||
onEdit,
|
||||
}: {
|
||||
proforma: Proforma;
|
||||
paymentMethodLabel: string | null;
|
||||
paymentTermLabel: string | null;
|
||||
onEdit: () => void;
|
||||
}) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Método de pago",
|
||||
value: paymentMethodLabel ?? "No informado",
|
||||
},
|
||||
{
|
||||
label: "Plazo",
|
||||
value: paymentTermLabel ?? "No informado",
|
||||
},
|
||||
{
|
||||
label: "Moneda",
|
||||
value: proforma.currencyCode,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<InspectorCard onEdit={onEdit} title="Cobro">
|
||||
<FieldValueList items={items} />
|
||||
</InspectorCard>
|
||||
);
|
||||
};
|
||||
|
||||
const DatesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Fecha proforma",
|
||||
value: formatDate(proforma.proformaDate),
|
||||
},
|
||||
{
|
||||
label: "Fecha operación",
|
||||
value: formatDate(proforma.operationDate),
|
||||
},
|
||||
{
|
||||
label: "Serie",
|
||||
value: proforma.series ?? "No informada",
|
||||
},
|
||||
{
|
||||
label: "Referencia interna",
|
||||
value: proforma.reference ?? "Sin referencia",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<InspectorCard onEdit={onEdit} title="Fechas y referencia">
|
||||
<FieldValueList items={items} />
|
||||
</InspectorCard>
|
||||
);
|
||||
};
|
||||
|
||||
const NotesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => {
|
||||
return (
|
||||
<InspectorCard 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>
|
||||
</InspectorCard>
|
||||
);
|
||||
};
|
||||
|
||||
const TotalsInspectorCard = ({ proforma }: { proforma: Proforma }) => {
|
||||
const items = [
|
||||
{
|
||||
label: "Subtotal",
|
||||
value: formatMoney(proforma.subtotalAmount, proforma.currencyCode),
|
||||
},
|
||||
{
|
||||
label: "Descuentos",
|
||||
value: `-${formatMoney(proforma.totalDiscountAmount, proforma.currencyCode)}`,
|
||||
valueClassName: "text-red-600",
|
||||
},
|
||||
{
|
||||
label: "IVA",
|
||||
value: formatMoney(proforma.ivaAmount, proforma.currencyCode),
|
||||
},
|
||||
{
|
||||
label: "Total",
|
||||
value: formatMoney(proforma.totalAmount, proforma.currencyCode),
|
||||
valueClassName: "text-lg font-semibold text-foreground",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<InspectorCard title="Resumen económico">
|
||||
<FieldValueList itemClassName="items-center" items={items} valueClassName="tabular-nums" />
|
||||
</InspectorCard>
|
||||
);
|
||||
};
|
||||
|
||||
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="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="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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderMeta = ({ proforma }: { proforma: Proforma }) => {
|
||||
const items = useMemo(
|
||||
() => [
|
||||
proforma.recipient.name ?? "Cliente sin nombre",
|
||||
formatDate(proforma.proformaDate),
|
||||
`Total ${formatMoney(proforma.totalAmount, proforma.currencyCode)}`,
|
||||
],
|
||||
[proforma]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm 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}
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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={
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<Button
|
||||
disabled={ctrl.issueDialogCtrl.isSubmitting}
|
||||
onClick={ctrl.primaryAction.onClick}
|
||||
type="button"
|
||||
>
|
||||
{ctrl.issueDialogCtrl.isSubmitting ? (
|
||||
<RefreshCwIcon className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{ctrl.primaryAction.label}
|
||||
</Button>
|
||||
|
||||
<Button onClick={ctrl.handleEdit} type="button" variant="outline">
|
||||
<PencilIcon className="mr-2 size-4" />
|
||||
Editar
|
||||
</Button>
|
||||
|
||||
<Button onClick={ctrl.handleDownload} type="button" variant="outline">
|
||||
<DownloadIcon className="mr-2 size-4" />
|
||||
Descargar PDF
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" type="button" variant="outline">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={ctrl.handleDuplicate}>
|
||||
Duplicar proforma
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={ctrl.handleArchiveToggle}>
|
||||
{ctrl.proforma.isArchived ? "Desarchivar" : "Archivar"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
}
|
||||
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} />
|
||||
<HeaderMeta 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]">
|
||||
<Card className="overflow-hidden border-border/80 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<PDFViewer
|
||||
className="h-[calc(100vh-11.5rem)] min-h-[720px]"
|
||||
defaultZoom="fit"
|
||||
description="Vista previa del PDF real generado para esta proforma."
|
||||
error={
|
||||
ctrl.previewPdfQuery.isError
|
||||
? ctrl.previewPdfQuery.error instanceof Error
|
||||
? ctrl.previewPdfQuery.error.message
|
||||
: "No se pudo cargar el PDF."
|
||||
: null
|
||||
}
|
||||
loading={ctrl.previewPdfQuery.isLoading || ctrl.previewPdfQuery.isFetching}
|
||||
onDownload={ctrl.handleDownload}
|
||||
onOpen={ctrl.handleOpenPreviewPdf}
|
||||
onPrint={ctrl.handlePrintPreview}
|
||||
src={ctrl.previewPdfUrl}
|
||||
title="Vista previa"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<StatusInspectorCard proforma={ctrl.proforma} />
|
||||
|
||||
<BillingInspectorCard
|
||||
onEdit={ctrl.handleEdit}
|
||||
paymentMethodLabel={ctrl.paymentMethodLabel}
|
||||
paymentTermLabel={ctrl.paymentTermLabel}
|
||||
proforma={ctrl.proforma}
|
||||
/>
|
||||
|
||||
<DatesInspectorCard onEdit={ctrl.handleEdit} proforma={ctrl.proforma} />
|
||||
|
||||
<NotesInspectorCard onEdit={ctrl.handleEdit} proforma={ctrl.proforma} />
|
||||
|
||||
<TotalsInspectorCard proforma={ctrl.proforma} />
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl">Líneas incluidas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Concepto</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ctrl.proforma.items.slice(0, 4).map((item: ProformaItem) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="max-w-[220px] truncate">
|
||||
{item.description ?? "Sin descripción"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatMoney(item.totalAmount, ctrl.proforma.currencyCode)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./update";
|
||||
export * from "./detail";
|
||||
|
||||
@ -43,6 +43,7 @@ import type { ProformaListScope } from "../../../types/proforma-list-filters";
|
||||
import { ProformaStatusBadge } from "../../components";
|
||||
|
||||
type GridActionHandlers = {
|
||||
onViewClick?: (proforma: ProformaListRow) => void;
|
||||
onPreviewClick?: (proforma: ProformaListRow) => void;
|
||||
onEditClick?: (proforma: ProformaListRow) => void;
|
||||
onIssueClick?: (proforma: ProformaListRow) => void;
|
||||
@ -297,6 +298,7 @@ export function useProformasGridColumns(
|
||||
: [];
|
||||
|
||||
const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick;
|
||||
const canView = !isDeletedScope && actionHandlers.onViewClick;
|
||||
const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick;
|
||||
const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick;
|
||||
const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick;
|
||||
@ -307,6 +309,7 @@ export function useProformasGridColumns(
|
||||
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
|
||||
const nextTransition = availableTransitions[0] ?? null;
|
||||
|
||||
const handleView = () => actionHandlers.onViewClick?.(proforma);
|
||||
const handleEdit = () => actionHandlers.onEditClick?.(proforma);
|
||||
const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma);
|
||||
const handleArchive = () => actionHandlers.onArchiveClick?.(proforma);
|
||||
@ -396,6 +399,13 @@ export function useProformasGridColumns(
|
||||
})();
|
||||
|
||||
const menuActions = [
|
||||
canView
|
||||
? {
|
||||
icon: ExternalLinkIcon,
|
||||
label: "Ver",
|
||||
onClick: handleView,
|
||||
}
|
||||
: null,
|
||||
!isIssued &&
|
||||
nextTransition &&
|
||||
actionHandlers.onChangeStatusClick &&
|
||||
|
||||
@ -238,6 +238,15 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenDetailClick = (proformaId: string) => {
|
||||
navigate({
|
||||
pathname: `/proformas/${proformaId}`,
|
||||
search: createSearchParams({
|
||||
returnTo: currentReturnTo,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateClick = async (proforma: ProformaListRow) => {
|
||||
try {
|
||||
const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id });
|
||||
@ -265,6 +274,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
};
|
||||
|
||||
const columns = useProformasGridColumns(scope, {
|
||||
onViewClick: (proforma) => handleOpenDetailClick(proforma.id),
|
||||
onEditClick: (proforma) => handleEditClick(proforma.id),
|
||||
onDuplicateClick: handleDuplicateClick,
|
||||
onIssueClick: (proformaRow) =>
|
||||
@ -323,9 +333,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
||||
onPageChange={listCtrl.setPageIndex}
|
||||
onPageSizeChange={listCtrl.setPageSize}
|
||||
onRowClick={
|
||||
scopeConfig.enablePanel
|
||||
? (proformaId) => panelCtrl.openProformaPanel(proformaId, "view")
|
||||
: undefined
|
||||
scope !== "deleted" ? (proformaId) => handleOpenDetailClick(proformaId) : undefined
|
||||
}
|
||||
onSortChange={listCtrl.setSort}
|
||||
pageIndex={listCtrl.pageIndex}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./proforma-layout";
|
||||
export * from "./proforma-page-header";
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import { PageFormHeader } from "@erp/core/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ProformaPageHeaderProps {
|
||||
title: string;
|
||||
backLabel?: string;
|
||||
onBack?: () => void;
|
||||
statusLabel?: string;
|
||||
showStatus?: boolean;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export const ProformaPageHeader = ({
|
||||
title,
|
||||
backLabel = "Volver",
|
||||
onBack,
|
||||
statusLabel,
|
||||
showStatus = false,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: ProformaPageHeaderProps) => {
|
||||
return (
|
||||
<PageFormHeader
|
||||
actions={actions}
|
||||
backLabel={backLabel}
|
||||
className={cn("shrink-0", className)}
|
||||
contentClassName={contentClassName}
|
||||
onBack={onBack}
|
||||
showStatus={showStatus}
|
||||
statusLabel={statusLabel}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</PageFormHeader>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
|
||||
import { PageKeyboardShortcutsButton } from "@erp/core/components";
|
||||
import {
|
||||
CancelActionButton,
|
||||
FormActionsBar,
|
||||
@ -8,6 +8,8 @@ import {
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ProformaPageHeader } from "../../../shared";
|
||||
|
||||
export interface ProformaUpdateHeaderLabels {
|
||||
title: string;
|
||||
back: string;
|
||||
@ -86,7 +88,7 @@ export const ProformaUpdateHeader = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<PageFormHeader
|
||||
<ProformaPageHeader
|
||||
actions={
|
||||
<FormActionsBar align="end" reverseOnMobile={false}>
|
||||
<PageKeyboardShortcutsButton
|
||||
@ -121,7 +123,6 @@ export const ProformaUpdateHeader = ({
|
||||
/>
|
||||
</FormActionsBar>
|
||||
}
|
||||
backLabel={labels.back}
|
||||
className={className}
|
||||
onBack={onCancel}
|
||||
showStatus={hasChanges}
|
||||
@ -129,6 +130,6 @@ export const ProformaUpdateHeader = ({
|
||||
title={labels.title}
|
||||
>
|
||||
{children}
|
||||
</PageFormHeader>
|
||||
</ProformaPageHeader>
|
||||
);
|
||||
};
|
||||
|
||||
1
packages/rdx-ui/src/components/field-value-list.ts
Normal file
1
packages/rdx-ui/src/components/field-value-list.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./field-value-list.tsx";
|
||||
45
packages/rdx-ui/src/components/field-value-list.tsx
Normal file
45
packages/rdx-ui/src/components/field-value-list.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
export interface FieldValueItem {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
interface FieldValueListProps {
|
||||
items: FieldValueItem[];
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
labelClassName?: string;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
export const FieldValueList = ({
|
||||
items,
|
||||
className,
|
||||
itemClassName,
|
||||
labelClassName,
|
||||
valueClassName,
|
||||
}: FieldValueListProps) => {
|
||||
return (
|
||||
<dl className={cn("space-y-3", className)}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
className={cn("flex items-start justify-between gap-4 text-sm", itemClassName)}
|
||||
key={item.label}
|
||||
>
|
||||
<dt className={cn("text-muted-foreground", labelClassName)}>{item.label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-right font-medium text-foreground text-balance",
|
||||
valueClassName,
|
||||
item.valueClassName
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
@ -4,6 +4,7 @@ export * from "./datatable/index.ts";
|
||||
export * from "./dynamics-tabs.tsx";
|
||||
export * from "./entity-sheet/index.ts";
|
||||
export * from "./error-overlay.tsx";
|
||||
export * from "./field-value-list.ts";
|
||||
export * from "./form/index.ts";
|
||||
export * from "./full-screen-modal.tsx";
|
||||
export * from "./initials-avatar.tsx";
|
||||
@ -12,6 +13,7 @@ export * from "./loading-overlay/index.ts";
|
||||
export * from "./logo-verifactu.tsx";
|
||||
export * from "./lookup-dialog/index.ts";
|
||||
export * from "./multi-select.tsx";
|
||||
export * from "./pdf-viewer.tsx";
|
||||
export * from "./right-panel/index.ts";
|
||||
export * from "./scroll-to-top.tsx";
|
||||
export * from "./tailwind-indicator.tsx";
|
||||
|
||||
283
packages/rdx-ui/src/components/pdf-viewer.tsx
Normal file
283
packages/rdx-ui/src/components/pdf-viewer.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Spinner,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { DownloadIcon, ExternalLinkIcon, PrinterIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_ZOOM_OPTIONS = [50, 75, 100, 125, 150];
|
||||
const FIT_ZOOM_VALUE = "fit";
|
||||
const PDF_PAGE_WIDTH = 794;
|
||||
const PDF_PAGE_HEIGHT = 1123;
|
||||
|
||||
type PDFViewerProps = {
|
||||
src?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
defaultZoom?: number | "fit";
|
||||
zoomOptions?: number[];
|
||||
onOpen?: () => void;
|
||||
onDownload?: () => void;
|
||||
onPrint?: () => void;
|
||||
};
|
||||
|
||||
const clampZoom = (value: number, zoomOptions: number[]) => {
|
||||
const min = Math.min(...zoomOptions);
|
||||
const max = Math.max(...zoomOptions);
|
||||
|
||||
return Math.min(Math.max(value, min), max);
|
||||
};
|
||||
|
||||
export function PDFViewer({
|
||||
src,
|
||||
title = "Vista previa",
|
||||
description = "Documento PDF embebido en la pantalla.",
|
||||
className,
|
||||
loading = false,
|
||||
error = null,
|
||||
defaultZoom = FIT_ZOOM_VALUE,
|
||||
zoomOptions = DEFAULT_ZOOM_OPTIONS,
|
||||
onOpen,
|
||||
onDownload,
|
||||
onPrint,
|
||||
}: 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 resolvedSrc = useMemo(() => {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separator = src.includes("#") ? "&" : "#";
|
||||
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom !== FIT_ZOOM_VALUE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = viewportRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFitZoom = () => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
const paddingX = Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight);
|
||||
const paddingY = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom);
|
||||
const availableWidth = Math.max(element.clientWidth - paddingX, 320);
|
||||
const availableHeight = Math.max(element.clientHeight - paddingY, 320);
|
||||
const widthScale = availableWidth / PDF_PAGE_WIDTH;
|
||||
const heightScale = availableHeight / PDF_PAGE_HEIGHT;
|
||||
const nextScale = Math.min(widthScale, heightScale, 1);
|
||||
setFitZoom(Math.max(Math.round(nextScale * 100), 25));
|
||||
};
|
||||
|
||||
updateFitZoom();
|
||||
|
||||
const observer = new ResizeObserver(() => updateFitZoom());
|
||||
observer.observe(element);
|
||||
window.addEventListener("resize", updateFitZoom);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", updateFitZoom);
|
||||
};
|
||||
}, [zoom]);
|
||||
|
||||
const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom);
|
||||
const zoomScale = effectiveZoom / 100;
|
||||
const minZoom = Math.min(...zoomOptions);
|
||||
const maxZoom = Math.max(...zoomOptions);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (src) {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.download = "";
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
if (onPrint) {
|
||||
onPrint();
|
||||
return;
|
||||
}
|
||||
|
||||
if (src) {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="flex flex-col gap-3 border-b px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) <= minZoom)}
|
||||
onClick={() =>
|
||||
setZoom((current) => {
|
||||
if (current === FIT_ZOOM_VALUE) {
|
||||
return String(Math.max(fitZoom - 25, minZoom));
|
||||
}
|
||||
|
||||
return String(clampZoom(Number(current) - 25, zoomOptions));
|
||||
})
|
||||
}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Select onValueChange={setZoom} value={zoom}>
|
||||
<SelectTrigger className="w-24 bg-background">
|
||||
<SelectValue placeholder="Zoom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FIT_ZOOM_VALUE}>Ajustar</SelectItem>
|
||||
{zoomOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
disabled={loading || !src || (zoom !== FIT_ZOOM_VALUE && Number(zoom) >= maxZoom)}
|
||||
onClick={() =>
|
||||
setZoom((current) => {
|
||||
if (current === FIT_ZOOM_VALUE) {
|
||||
return String(clampZoom(fitZoom + 25, zoomOptions));
|
||||
}
|
||||
|
||||
return String(clampZoom(Number(current) + 25, zoomOptions));
|
||||
})
|
||||
}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
|
||||
<ExternalLinkIcon className="mr-2 size-4" />
|
||||
Abrir PDF
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={loading || !src}
|
||||
onClick={handleDownload}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<DownloadIcon className="mr-2 size-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={loading || !src}
|
||||
onClick={handlePrint}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PrinterIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-muted/30 p-4 md:p-5" ref={viewportRef}>
|
||||
{loading ? (
|
||||
<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...
|
||||
</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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!(loading || error) && resolvedSrc ? (
|
||||
<div className="mx-auto flex min-h-full justify-center">
|
||||
<div
|
||||
className="origin-top rounded-xl border bg-background shadow-sm transition-transform"
|
||||
style={{
|
||||
transform: `scale(${zoomScale})`,
|
||||
transformOrigin: "top center",
|
||||
width: `${PDF_PAGE_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
className="w-full rounded-xl"
|
||||
src={resolvedSrc}
|
||||
style={{ height: `${PDF_PAGE_HEIGHT}px` }}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading || error || resolvedSrc ? null : (
|
||||
<div className="flex h-full min-h-[480px] items-center justify-center rounded-xl border border-dashed bg-background/80 px-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">Vista previa no disponible</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
El PDF de la proforma todavía no está listo para mostrarse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user