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
220 lines
No EOL
6.5 KiB
TypeScript
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; |