Mejorar la vistaprevia PDF
This commit is contained in:
parent
97cafb3818
commit
f8bd2f3078
@ -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;
|
||||
|
||||
@ -135,6 +135,7 @@ export type ProformaDetailActionKey =
|
||||
| "change-status"
|
||||
| "issue"
|
||||
| "duplicate"
|
||||
| "open-pdf"
|
||||
| "download-pdf"
|
||||
| "archive"
|
||||
| "unarchive";
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user