WFDASH-WEBAPP/src/components/quote-pdf-viewer.tsx
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

220 lines
No EOL
6.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { X, Printer, Download, Loader2, ExternalLink } from "lucide-react";
/**
* In-app PDF viewer modal for a generated quote PDF.
*
* Flow:
* - On mount, fetches the PDF binary from the API as a Blob, creates
* a local blob URL, and renders it inside an `<iframe>` (browser
* native PDF viewer).
* - Print button: asks the iframe to call its own `window.print()` —
* prints the PDF directly, not the surrounding page chrome.
* - Download button: fetches again with `?download=1` to get a
* `Content-Disposition: attachment` response, triggers a save dialog.
* - Open in new tab: opens the blob URL in a new browser tab for
* users who want a full-page viewing experience instead of the
* in-modal preview.
* - Close: revokes the blob URL and unmounts.
*/
export interface QuotePdfViewerProps {
open: boolean;
quoteId: string;
attachmentId: string;
filename: string;
onClose: () => void;
}
export function QuotePdfViewer({
open,
quoteId,
attachmentId,
filename,
onClose,
}: QuotePdfViewerProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
// Fetch + blob-URL-ify when the modal opens.
useEffect(() => {
if (!open) {
// Cleanup when closing.
setBlobUrl(null);
setLoading(true);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = await fetch(
`/api/v1/quotes/${quoteId}/pdf?attachmentId=${encodeURIComponent(attachmentId)}`
);
if (!res.ok) {
throw new Error(`Failed to load PDF (${res.status})`);
}
const blob = await res.blob();
if (cancelled) return;
const url = URL.createObjectURL(blob);
setBlobUrl(url);
setLoading(false);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, quoteId, attachmentId]);
// Revoke blob URL on unmount or when it changes.
useEffect(() => {
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [blobUrl]);
// Esc closes the modal.
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open) return null;
const handlePrint = () => {
// Print from inside the iframe so only the PDF prints, not the modal chrome.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Some browsers block cross-frame print; fall back to opening the blob URL.
if (blobUrl) window.open(blobUrl, "_blank");
}
};
const handleDownload = async () => {
// Re-fetch with ?download=1 so the server sends Content-Disposition: attachment.
const url = `/api/v1/quotes/${quoteId}/pdf?attachmentId=${encodeURIComponent(
attachmentId
)}&download=1`;
// Anchor click is the cleanest cross-browser way to trigger a save dialog.
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
};
const handleOpenInNewTab = () => {
if (!blobUrl) return;
// Open the same blob URL in a new tab — the browser's built-in
// PDF viewer takes over (with its own toolbar / download button).
window.open(blobUrl, "_blank", "noopener,noreferrer");
};
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/70"
role="dialog"
aria-modal="true"
aria-label="Quote PDF viewer"
data-testid="quote-pdf-viewer"
>
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 border-b border-white/10 bg-zinc-900 px-4 py-2 text-white">
<div className="flex items-center gap-3 min-w-0">
<span className="font-medium truncate" title={filename}>
{filename}
</span>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handlePrint}
disabled={!blobUrl || loading}
data-testid="quote-pdf-print"
>
<Printer className="mr-1 h-4 w-4" />
Print
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleDownload}
disabled={!blobUrl || loading}
data-testid="quote-pdf-download"
>
<Download className="mr-1 h-4 w-4" />
Download
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleOpenInNewTab}
disabled={!blobUrl || loading}
data-testid="quote-pdf-open-new-tab"
>
<ExternalLink className="mr-1 h-4 w-4" />
Open in new tab
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="text-white hover:bg-white/10"
aria-label="Close PDF viewer"
data-testid="quote-pdf-close"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
{/* PDF area */}
<div className="relative flex-1 bg-zinc-800">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center text-white">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading PDF…
</div>
) : error ? (
<div className="absolute inset-0 flex items-center justify-center text-destructive">
{error}
</div>
) : blobUrl ? (
<iframe
ref={iframeRef}
src={blobUrl}
title={filename}
className="h-full w-full border-0 bg-white"
data-testid="quote-pdf-iframe"
/>
) : null}
</div>
</div>
);
}
export default QuotePdfViewer;