Uecko_ERP/packages/rdx-ui/src/components/pdf-viewer.tsx
david 1506a78582 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.
2026-07-30 21:04:26 +02:00

284 lines
8.5 KiB
TypeScript

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>
);
}