- 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.
284 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|