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 = () => { const handleOpenPreviewPdf = async () => {
if (!previewPdfUrl) { if (previewPdfUrl) {
window.open(previewPdfUrl, "_blank", "noopener,noreferrer");
return; 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 = () => { const handlePrintPreview = () => {
@ -379,6 +403,7 @@ export const useProformaDetailPageController = () => {
changeStatus: t("pages.proformas.list.actions.change_status"), changeStatus: t("pages.proformas.list.actions.change_status"),
issue: t("pages.proformas.list.actions.issue"), issue: t("pages.proformas.list.actions.issue"),
duplicate: t("pages.proformas.list.actions.duplicate"), duplicate: t("pages.proformas.list.actions.duplicate"),
openPdf: t("proformas.detail.document.actions.open"),
downloadPdf: t("pages.proformas.list.actions.download_pdf"), downloadPdf: t("pages.proformas.list.actions.download_pdf"),
archive: t("pages.proformas.list.actions.archive"), archive: t("pages.proformas.list.actions.archive"),
unarchive: t("pages.proformas.list.actions.unarchive"), unarchive: t("pages.proformas.list.actions.unarchive"),
@ -398,6 +423,7 @@ export const useProformaDetailPageController = () => {
loading: { loading: {
issue: issueDialogCtrl.isSubmitting, issue: issueDialogCtrl.isSubmitting,
duplicate: duplicateMutation.isPending, duplicate: duplicateMutation.isPending,
"open-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
"download-pdf": previewPdfQuery.isFetching && !previewPdfBlob, "download-pdf": previewPdfQuery.isFetching && !previewPdfBlob,
archive: archiveMutation.isPending, archive: archiveMutation.isPending,
unarchive: unarchiveMutation.isPending, unarchive: unarchiveMutation.isPending,
@ -438,6 +464,14 @@ export const useProformaDetailPageController = () => {
case "duplicate": case "duplicate":
handleDuplicate(); handleDuplicate();
return; 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": case "download-pdf":
handleDownload(); handleDownload();
return; return;

View File

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

View File

@ -10,6 +10,7 @@ import {
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
FileOutputIcon, FileOutputIcon,
ExternalLinkIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
PencilIcon, PencilIcon,
RefreshCwIcon, RefreshCwIcon,
@ -30,9 +31,7 @@ export const ProformaDetailHeaderActions = ({
onExecuteAction, onExecuteAction,
}: ProformaDetailHeaderActionsProps) => { }: ProformaDetailHeaderActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const visibleItems = actions.items.filter( const visibleItems = actions.items.filter((item) => item.visibility === "visible");
(item) => item.visibility === "visible" && item.key !== "download-pdf"
);
const primaryAction = visibleItems.find((item) => item.placement === "primary") ?? null; const primaryAction = visibleItems.find((item) => item.placement === "primary") ?? null;
const secondaryActions = visibleItems.filter((item) => item.placement === "secondary"); const secondaryActions = visibleItems.filter((item) => item.placement === "secondary");
const menuActions = visibleItems.filter((item) => item.placement === "menu"); const menuActions = visibleItems.filter((item) => item.placement === "menu");
@ -45,6 +44,8 @@ export const ProformaDetailHeaderActions = ({
return FileOutputIcon; return FileOutputIcon;
case "duplicate": case "duplicate":
return CopyIcon; return CopyIcon;
case "open-pdf":
return ExternalLinkIcon;
case "download-pdf": case "download-pdf":
return DownloadIcon; return DownloadIcon;
case "archive": case "archive":

View File

@ -1,7 +1,7 @@
import { PDFViewer } from "@repo/rdx-ui/components"; import { PDFViewer } from "@repo/rdx-ui/components";
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { useId } from "react"; import { useEffect, useId, useState } from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities"; import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities";
@ -25,22 +25,51 @@ export const ProformaDocumentPane = ({
}: ProformaDocumentPaneProps) => { }: ProformaDocumentPaneProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const headingId = useId(); 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 isRefreshing = state.isFetching && Boolean(document.src);
const shouldRenderBlockingError = state.isError && !document.src; const shouldRenderBlockingError = state.isError && !document.src;
const shouldRenderStaleError = state.isError && Boolean(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 ( return (
<section <section
aria-labelledby={headingId} 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" 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}> <h2 className="sr-only" id={headingId}>
{document.title} {document.title}
</h2> </h2>
{shouldRenderStaleError ? ( {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> <AlertTitle>{t("proformas.detail.document.error_title")}</AlertTitle>
<AlertDescription className="space-y-3"> <AlertDescription className="space-y-3">
<p>{state.errorMessage ?? t("proformas.detail.document.error")}</p> <p>{state.errorMessage ?? t("proformas.detail.document.error")}</p>
@ -55,7 +84,8 @@ export const ProformaDocumentPane = ({
<PDFViewer <PDFViewer
className={cn( 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" defaultZoom="fit"
description={document.description} description={document.description}
@ -81,8 +111,12 @@ export const ProformaDocumentPane = ({
onOpen={onOpen} onOpen={onOpen}
onPrint={onPrint} onPrint={onPrint}
onRetry={onRetry} onRetry={onRetry}
showDownloadAction={false}
showOpenAction={false}
showPrinterAction={false}
src={document.src} src={document.src}
title={document.title} title={document.title}
variant={shouldUseNativeInlinePdf ? "native-inline" : "panel"}
/> />
</div> </div>
</section> </section>

View File

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

View File

@ -11,6 +11,7 @@ import {
SelectValue, SelectValue,
Spinner, Spinner,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { import {
DownloadIcon, DownloadIcon,
ExternalLinkIcon, ExternalLinkIcon,
@ -47,6 +48,10 @@ type PDFViewerProps = {
title?: string; title?: string;
description?: string; description?: string;
className?: string; className?: string;
variant?: "panel" | "native-inline";
showOpenAction?: boolean;
showDownloadAction?: boolean;
showPrinterAction?: boolean;
loading?: boolean; loading?: boolean;
isRefreshing?: boolean; isRefreshing?: boolean;
error?: string | null; error?: string | null;
@ -71,6 +76,10 @@ export function PDFViewer({
title = "Vista previa", title = "Vista previa",
description = "Documento PDF embebido en la pantalla.", description = "Documento PDF embebido en la pantalla.",
className, className,
variant = "panel",
showOpenAction = true,
showDownloadAction = true,
showPrinterAction = true,
loading = false, loading = false,
isRefreshing = false, isRefreshing = false,
error = null, error = null,
@ -87,6 +96,8 @@ export function PDFViewer({
const [zoom, setZoom] = useState<string>(() => const [zoom, setZoom] = useState<string>(() =>
defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions)) defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions))
); );
const minZoom = Math.min(...zoomOptions);
const maxZoom = Math.max(...zoomOptions);
const resolvedLabels: PDFViewerLabels = { const resolvedLabels: PDFViewerLabels = {
zoomOut: "Alejar", zoomOut: "Alejar",
zoomIn: "Acercar", zoomIn: "Acercar",
@ -109,9 +120,13 @@ export function PDFViewer({
return null; return null;
} }
if (variant === "native-inline") {
return src;
}
const separator = src.includes("#") ? "&" : "#"; const separator = src.includes("#") ? "&" : "#";
return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`; return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&page=1&zoom=page-width`;
}, [src]); }, [src, variant]);
useEffect(() => { useEffect(() => {
if (zoom !== FIT_ZOOM_VALUE) { if (zoom !== FIT_ZOOM_VALUE) {
@ -127,13 +142,9 @@ export function PDFViewer({
const styles = window.getComputedStyle(element); const styles = window.getComputedStyle(element);
const paddingX = const paddingX =
Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight); 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 availableWidth = Math.max(element.clientWidth - paddingX, 320);
const availableHeight = Math.max(element.clientHeight - paddingY, 320);
const widthScale = availableWidth / PDF_PAGE_WIDTH; const widthScale = availableWidth / PDF_PAGE_WIDTH;
const heightScale = availableHeight / PDF_PAGE_HEIGHT; const nextScale = Math.min(widthScale, maxZoom / 100);
const nextScale = Math.min(widthScale, heightScale, 1);
setFitZoom(Math.max(Math.round(nextScale * 100), 25)); setFitZoom(Math.max(Math.round(nextScale * 100), 25));
}; };
@ -147,12 +158,11 @@ export function PDFViewer({
observer.disconnect(); observer.disconnect();
window.removeEventListener("resize", updateFitZoom); window.removeEventListener("resize", updateFitZoom);
}; };
}, [zoom]); }, [maxZoom, zoom]);
const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom); const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom);
const zoomScale = effectiveZoom / 100; const zoomScale = effectiveZoom / 100;
const minZoom = Math.min(...zoomOptions); const scaledPageHeight = PDF_PAGE_HEIGHT * zoomScale;
const maxZoom = Math.max(...zoomOptions);
const handleOpen = () => { const handleOpen = () => {
if (onOpen) { 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 ( return (
<FormSectionCard <FormSectionCard
actions={ actions={
@ -248,31 +349,37 @@ export function PDFViewer({
<ZoomInIcon className="size-4" /> <ZoomInIcon className="size-4" />
</Button> </Button>
<Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline"> {showOpenAction ? (
<ExternalLinkIcon className="mr-2 size-4" /> <Button disabled={loading || !src} onClick={handleOpen} type="button" variant="outline">
{resolvedLabels.open} <ExternalLinkIcon className="mr-2 size-4" />
</Button> {resolvedLabels.open}
</Button>
) : null}
<Button {showDownloadAction ? (
disabled={loading || !src} <Button
onClick={handleDownload} disabled={loading || !src}
type="button" onClick={handleDownload}
variant="outline" type="button"
> variant="outline"
<DownloadIcon className="mr-2 size-4" /> >
{resolvedLabels.download} <DownloadIcon className="mr-2 size-4" />
</Button> {resolvedLabels.download}
</Button>
) : null}
<Button {showPrinterAction ? (
aria-label={resolvedLabels.print} <Button
disabled={loading || !src} aria-label={resolvedLabels.print}
onClick={handlePrint} disabled={loading || !src}
size="icon-sm" onClick={handlePrint}
type="button" size="icon-sm"
variant="outline" type="button"
> variant="outline"
<PrinterIcon className="size-4" /> >
</Button> <PrinterIcon className="size-4" />
</Button>
) : null}
{isRefreshing && src ? ( {isRefreshing && src ? (
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground"> <div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
@ -282,71 +389,14 @@ export function PDFViewer({
) : null} ) : null}
</div> </div>
} }
className="shadow-sm" className={cn("shadow-sm", className)}
contentClassName="space-y-5" contentClassName="space-y-3 px-2 sm:space-y-4 sm:px-4 md:space-y-5 md:px-6"
description={description} description={description}
headerClassName="px-2 sm:px-4 md:px-6"
icon={<EyeIcon className="size-5" />} icon={<EyeIcon className="size-5" />}
title={title} title={title}
> >
<FormSectionGrid className="gap-y-6 md:grid-cols-1"> {viewerContent}
<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>
</FormSectionCard> </FormSectionCard>
); );
} }