FactuGES_ERP/packages/rdx-ui/src/components/pdf-viewer.tsx

295 lines
8.6 KiB
TypeScript
Raw Normal View History

2026-08-04 15:15:57 +00:00
import { FormSectionCard, FormSectionGrid } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
} from "@repo/shadcn-ui/components";
2026-08-04 15:15:57 +00:00
import {
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
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);
2026-08-04 15:15:57 +00:00
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 (
2026-08-04 15:15:57 +00:00
<FormSectionCard
actions={
<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>
2026-08-04 15:15:57 +00:00
}
className="shadow-sm"
contentClassName="space-y-5"
description={description}
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" />
Cargando vista previa del PDF...
</div>
</div>
2026-08-04 15:15:57 +00:00
) : 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>
2026-08-04 15:15:57 +00:00
) : 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>
2026-08-04 15:15:57 +00:00
) : 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>
2026-08-04 15:15:57 +00:00
)}
</div>
</FormSectionGrid>
</FormSectionCard>
);
}