Mejorar la vistaprevia PDF

This commit is contained in:
David Arranz 2026-08-11 10:57:17 +02:00
parent 97cafb3818
commit f8bd2f3078
6 changed files with 235 additions and 105 deletions

View File

@ -276,12 +276,36 @@ export const useProformaDetailPageController = () => {
});
};
const handleOpenPreviewPdf = () => {
if (!previewPdfUrl) {
const handleOpenPreviewPdf = async () => {
if (previewPdfUrl) {
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
return;
}
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
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;
}
const objectUrl = URL.createObjectURL(result.data);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => {
URL.revokeObjectURL(objectUrl);
}, 60_000);
};
const handlePrintPreview = () => {
@ -379,6 +403,7 @@ export const useProformaDetailPageController = () => {
changeStatus: t("pages.proformas.list.actions.change_status"),
issue: t("pages.proformas.list.actions.issue"),
duplicate: t("pages.proformas.list.actions.duplicate"),
openPdf: t("proformas.detail.document.actions.open"),
downloadPdf: t("pages.proformas.list.actions.download_pdf"),
archive: t("pages.proformas.list.actions.archive"),
unarchive: t("pages.proformas.list.actions.unarchive"),
@ -398,6 +423,7 @@ export const useProformaDetailPageController = () => {
loading: {
issue: issueDialogCtrl.isSubmitting,
duplicate: duplicateMutation.isPending,
"open-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
"download-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
archive: archiveMutation.isPending,
unarchive: unarchiveMutation.isPending,
@ -438,6 +464,14 @@ export const useProformaDetailPageController = () => {
case "duplicate":
handleDuplicate();
return;
case "open-pdf":
handleOpenPreviewPdf().catch((error: unknown) => {
showErrorToast(
t("proformas.detail.document.error_title"),
error instanceof Error ? error.message : t("proformas.detail.document.error")
);
});
return;
case "download-pdf":
handleDownload();
return;

View File

@ -135,6 +135,7 @@ export type ProformaDetailActionKey =
| "change-status"
| "issue"
| "duplicate"
| "open-pdf"
| "download-pdf"
| "archive"
| "unarchive";

View File

@ -10,6 +10,7 @@ import {
CopyIcon,
DownloadIcon,
FileOutputIcon,
ExternalLinkIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
@ -30,9 +31,7 @@ export const ProformaDetailHeaderActions = ({
onExecuteAction,
}: ProformaDetailHeaderActionsProps) => {
const { t } = useTranslation();
const visibleItems = actions.items.filter(
(item) => item.visibility === "visible" && item.key !== "download-pdf"
);
const visibleItems = actions.items.filter((item) => item.visibility === "visible");
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");
@ -45,6 +44,8 @@ export const ProformaDetailHeaderActions = ({
return FileOutputIcon;
case "duplicate":
return CopyIcon;
case "open-pdf":
return ExternalLinkIcon;
case "download-pdf":
return DownloadIcon;
case "archive":

View File

@ -1,7 +1,7 @@
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 { useEffect, useId, useState } from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities";
@ -25,22 +25,51 @@ export const ProformaDocumentPane = ({
}: ProformaDocumentPaneProps) => {
const { t } = useTranslation();
const headingId = useId();
const [isDesktopViewport, setIsDesktopViewport] = useState(() =>
typeof window === "undefined" ? false : window.matchMedia("(min-width: 1024px)").matches
);
const supportsNativeInlinePdf =
typeof navigator !== "undefined" && navigator.pdfViewerEnabled === true;
const shouldUseNativeInlinePdf = supportsNativeInlinePdf && isDesktopViewport;
const isRefreshing = state.isFetching && Boolean(document.src);
const shouldRenderBlockingError = state.isError && !document.src;
const shouldRenderStaleError = state.isError && Boolean(document.src);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = (event: MediaQueryListEvent) => {
setIsDesktopViewport(event.matches);
};
setIsDesktopViewport(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
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">
<div
className={cn(
"h-full min-h-0",
shouldUseNativeInlinePdf ? "flex flex-col overflow-hidden p-0" : "p-2 sm:p-4 lg:p-6"
)}
>
<h2 className="sr-only" id={headingId}>
{document.title}
</h2>
{shouldRenderStaleError ? (
<Alert className="mb-4">
<Alert className={cn(shouldUseNativeInlinePdf ? "m-4 mb-0 shrink-0" : "mb-4")}>
<AlertTitle>{t("proformas.detail.document.error_title")}</AlertTitle>
<AlertDescription className="space-y-3">
<p>{state.errorMessage ?? t("proformas.detail.document.error")}</p>
@ -55,7 +84,8 @@ export const ProformaDocumentPane = ({
<PDFViewer
className={cn(
"min-h-[32rem] overflow-hidden border-border/80 bg-background shadow-none lg:h-full lg:min-h-full"
"min-h-[56svh] overflow-hidden border-border/80 bg-background shadow-none sm:min-h-[32rem] lg:h-full lg:min-h-full",
shouldUseNativeInlinePdf && "flex-1 min-h-0"
)}
defaultZoom="fit"
description={document.description}
@ -81,8 +111,12 @@ export const ProformaDocumentPane = ({
onOpen={onOpen}
onPrint={onPrint}
onRetry={onRetry}
showDownloadAction={false}
showOpenAction={false}
showPrinterAction={false}
src={document.src}
title={document.title}
variant={shouldUseNativeInlinePdf ? "native-inline" : "panel"}
/>
</div>
</section>

View File

@ -11,6 +11,7 @@ export interface ProformaDetailActionLabels {
changeStatus: string;
issue: string;
duplicate: string;
openPdf: string;
downloadPdf: string;
archive: string;
unarchive: string;
@ -120,10 +121,19 @@ export const buildProformaDetailActions = ({
loading.duplicate ?? false,
null
),
buildAction(
"open-pdf",
labels.openPdf,
"secondary",
"visible",
!(loading["open-pdf"] ?? false),
loading["open-pdf"] ?? false,
loading["open-pdf"] ? labels.disabledReasons.operationInProgress : null
),
buildAction(
"download-pdf",
labels.downloadPdf,
isApproved ? "secondary" : "secondary",
"secondary",
"visible",
!(loading["download-pdf"] ?? false),
loading["download-pdf"] ?? false,

View File

@ -11,6 +11,7 @@ import {
SelectValue,
Spinner,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
DownloadIcon,
ExternalLinkIcon,
@ -47,6 +48,10 @@ type PDFViewerProps = {
title?: string;
description?: string;
className?: string;
variant?: "panel" | "native-inline";
showOpenAction?: boolean;
showDownloadAction?: boolean;
showPrinterAction?: boolean;
loading?: boolean;
isRefreshing?: boolean;
error?: string | null;
@ -71,6 +76,10 @@ export function PDFViewer({
title = "Vista previa",
description = "Documento PDF embebido en la pantalla.",
className,
variant = "panel",
showOpenAction = true,
showDownloadAction = true,
showPrinterAction = true,
loading = false,
isRefreshing = false,
error = null,
@ -87,6 +96,8 @@ export function PDFViewer({
const [zoom, setZoom] = useState<string>(() =>
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
);
const minZoom = Math.min(...zoomOptions);
const maxZoom = Math.max(...zoomOptions);
const resolvedLabels: PDFViewerLabels = {
zoomOut: "Alejar",
zoomIn: "Acercar",
@ -109,9 +120,13 @@ export function PDFViewer({
return null;
}
if (variant === "native-inline") {
return src;
}
const separator = src.includes("#") ? "&" : "#";
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
}, [src]);
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&page=1&zoom=page-width`;
}, [src, variant]);
useEffect(() => {
if (zoom !== FIT_ZOOM_VALUE) {
@ -127,13 +142,9 @@ export function PDFViewer({
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);
const nextScale = Math.min(widthScale, maxZoom / 100);
setFitZoom(Math.max(Math.round(nextScale * 100), 25));
};
@ -147,12 +158,11 @@ export function PDFViewer({
observer.disconnect();
window.removeEventListener("resize", updateFitZoom);
};
}, [zoom]);
}, [maxZoom, 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 scaledPageHeight = PDF_PAGE_HEIGHT * zoomScale;
const handleOpen = () => {
if (onOpen) {
@ -192,6 +202,97 @@ export function PDFViewer({
}
};
const viewerContent = (
<FormSectionGrid
className={cn("gap-y-6 md:grid-cols-1", variant === "native-inline" && "h-full")}
>
<div
className={cn(
"flex-1 overflow-auto bg-muted p-1.5 sm:p-3 md:p-5",
variant === "native-inline" && "flex h-full min-h-0 flex-col bg-transparent p-0 md:p-0"
)}
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" />
{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">
<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}
{!(loading || error) && resolvedSrc ? (
<div
className={cn(
"mx-auto flex min-h-full justify-center",
variant === "native-inline" && "flex-1 min-h-0 w-full"
)}
style={variant === "native-inline" ? undefined : { minHeight: `${scaledPageHeight}px` }}
>
{variant === "native-inline" ? (
<iframe
className="h-full min-h-0 w-full bg-background"
src={resolvedSrc}
style={{ border: "0" }}
title={title}
/>
) : (
<div
className="origin-top overflow-hidden rounded-lg border border-black/8 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.10),0_2px_8px_rgba(15,23,42,0.06)] transition-transform"
style={{
transform: `scale(${zoomScale})`,
transformOrigin: "top center",
width: `${PDF_PAGE_WIDTH}px`,
}}
>
<iframe
className="block w-full bg-white"
src={resolvedSrc}
style={{ border: "0", 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">{resolvedLabels.emptyTitle}</p>
<p className="text-sm text-muted-foreground">{resolvedLabels.emptyDescription}</p>
</div>
</div>
)}
</div>
</FormSectionGrid>
);
if (variant === "native-inline") {
return (
<div className={cn("flex h-full min-h-[32rem] min-h-0 flex-col", className)}>
{viewerContent}
</div>
);
}
return (
<FormSectionCard
actions={
@ -248,31 +349,37 @@ export function PDFViewer({
<ZoomInIcon className="size-4" />
</Button>
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
<ExternalLinkIcon className="mr-2 size-4" />
{resolvedLabels.open}
</Button>
{showOpenAction ? (
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
<ExternalLinkIcon className="mr-2 size-4" />
{resolvedLabels.open}
</Button>
) : null}
<Button
disabled={loading || !src}
onClick={handleDownload}
type="button"
variant="outline"
>
<DownloadIcon className="mr-2 size-4" />
{resolvedLabels.download}
</Button>
{showDownloadAction ? (
<Button
disabled={loading || !src}
onClick={handleDownload}
type="button"
variant="outline"
>
<DownloadIcon className="mr-2 size-4" />
{resolvedLabels.download}
</Button>
) : null}
<Button
aria-label={resolvedLabels.print}
disabled={loading || !src}
onClick={handlePrint}
size="icon-sm"
type="button"
variant="outline"
>
<PrinterIcon className="size-4" />
</Button>
{showPrinterAction ? (
<Button
aria-label={resolvedLabels.print}
disabled={loading || !src}
onClick={handlePrint}
size="icon-sm"
type="button"
variant="outline"
>
<PrinterIcon className="size-4" />
</Button>
) : null}
{isRefreshing && src ? (
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
@ -282,71 +389,14 @@ export function PDFViewer({
) : null}
</div>
}
className="shadow-sm"
contentClassName="space-y-5"
className={cn("shadow-sm", className)}
contentClassName="space-y-3 px-2 sm:space-y-4 sm:px-4 md:space-y-5 md:px-6"
description={description}
headerClassName="px-2 sm:px-4 md:px-6"
icon={<EyeIcon className="size-5" />}
title={title}
>
<FormSectionGrid className="gap-y-6 md:grid-cols-1">
<div className="flex-1 overflow-auto bg-muted 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" />
{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">
<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}
{!(loading || error) && resolvedSrc ? (
<div className="mx-auto flex min-h-full justify-center">
<div
className="origin-top rounded-xl border bg-red-500 shadow-sm transition-transform"
style={{
transform: `scale(${zoomScale})`,
transformOrigin: "top center",
width: `${PDF_PAGE_WIDTH}px`,
}}
>
<iframe
className="w-full rounded-xl bg-amber-500"
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">{resolvedLabels.emptyTitle}</p>
<p className="text-sm text-muted-foreground">
{resolvedLabels.emptyDescription}
</p>
</div>
</div>
)}
</div>
</FormSectionGrid>
{viewerContent}
</FormSectionCard>
);
}