feat(reports): PDF preview modal (phase D — feature complete)
Closes out the report exporter. Adds a Preview button alongside
Download on every export dialog (dashboard + 3 list kinds). The
modal POSTs the current form payload to /api/v1/reports/generate,
renders the resulting Blob in a sandboxed iframe via
URL.createObjectURL, and exposes the cached Blob to the Download
button so committing the download doesn't re-fetch.
PdfPreviewModal:
- Re-fetches when the payload changes (rep tweaks config, opens
preview again — fresh PDF every time).
- Cleans up the object URL on close + on unmount, no leak.
- sandbox="allow-same-origin" lets the iframe read the blob URL
but blocks any embedded scripts from reaching cookies /
LocalStorage.
- Surfaces preview failures inline instead of a toast so the rep
can read the error without dismissing the modal.
UI integration:
- Both ExportDashboardPdfButton + ExportListPdfButton gain an
"Eye" Preview button between Cancel and Download.
- previewPayload is memoised on the form state so the modal's
fetch effect only re-fires when the rep actually changes
something.
Verified: tsc clean, vitest 1454/1454. Manual end-to-end test
(open a real dashboard, pick widgets, preview, download) is the
next gate; build is production-ready otherwise.
Final exporter shape (phases A → D):
- 4 report kinds: dashboard / clients / berths / interests
- Per-port branding: logo + primary color (luminance-checked
accent foreground for AA contrast on dark brands)
- Customizable: widget picker for dashboard, include-archived
toggle, custom title, save-as-template, apply saved template
- Preview modal with sandboxed iframe + cached Blob for Download
- 1 000-row export cap with "Showing top N of <total>" notice
- Permission-gated on reports.export server-side + client-side
- Audit-logged on every successful generation
- RFC 5987 Content-Disposition for unicode filenames
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -24,6 +24,7 @@ import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||
import { PdfPreviewModal } from './pdf-preview-modal';
|
||||
|
||||
/**
|
||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||||
@@ -44,6 +45,18 @@ export function ExportDashboardPdfButton() {
|
||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
// Build the payload the modal will POST. useMemo keeps the
|
||||
// reference stable while the dialog's form is unchanged, so the
|
||||
// preview effect doesn't re-fire on unrelated re-renders.
|
||||
const previewPayload = useMemo(
|
||||
() => ({
|
||||
title: title.trim() || 'Dashboard report',
|
||||
config: { kind: 'dashboard' as const, widgetIds: selected },
|
||||
}),
|
||||
[title, selected],
|
||||
);
|
||||
|
||||
if (!can('reports', 'export')) return null;
|
||||
|
||||
@@ -159,6 +172,14 @@ export function ExportDashboardPdfButton() {
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
disabled={loading || selected.length === 0}
|
||||
>
|
||||
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Preview
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||
@@ -170,6 +191,15 @@ export function ExportDashboardPdfButton() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{previewOpen ? (
|
||||
<PdfPreviewModal
|
||||
open
|
||||
onOpenChange={setPreviewOpen}
|
||||
payload={previewPayload}
|
||||
filename={`${title.trim().replace(/[\\/]/g, '_') || 'dashboard-report'}.pdf`}
|
||||
title={`Preview: ${title.trim() || 'Dashboard report'}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +20,7 @@ import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||
import { PdfPreviewModal } from './pdf-preview-modal';
|
||||
|
||||
type ListKind = 'clients' | 'berths' | 'interests';
|
||||
|
||||
@@ -55,6 +56,15 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
|
||||
);
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const previewPayload = useMemo(
|
||||
() => ({
|
||||
title: title.trim() || `${kind} report`,
|
||||
config: { kind, filters: { includeArchived } },
|
||||
}),
|
||||
[title, kind, includeArchived],
|
||||
);
|
||||
|
||||
if (!can('reports', 'export')) return null;
|
||||
|
||||
@@ -143,6 +153,10 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setPreviewOpen(true)} disabled={loading}>
|
||||
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Preview
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||
@@ -154,6 +168,15 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{previewOpen ? (
|
||||
<PdfPreviewModal
|
||||
open
|
||||
onOpenChange={setPreviewOpen}
|
||||
payload={previewPayload}
|
||||
filename={`${title.trim().replace(/[\\/]/g, '_') || `${kind}-report`}.pdf`}
|
||||
title={`Preview: ${title.trim() || `${kind} report`}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
174
src/components/reports/pdf-preview-modal.tsx
Normal file
174
src/components/reports/pdf-preview-modal.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Loader2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
/** JSON body forwarded to /api/v1/reports/generate. */
|
||||
payload: unknown;
|
||||
/** Filename used when the rep clicks Download. */
|
||||
filename: string;
|
||||
/** Dialog header shown above the iframe. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview-then-download flow for a generated PDF.
|
||||
*
|
||||
* The modal POSTs the supplied payload to `/api/v1/reports/generate`
|
||||
* on open, holds the resulting Blob in component state, and renders
|
||||
* it in an iframe via `URL.createObjectURL`. The Download button
|
||||
* reuses the cached Blob — no second network round-trip on commit.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - On open: fetch + revoke any previous URL
|
||||
* - On payload change: re-fetch (the rep edited config in the
|
||||
* parent dialog and re-opened preview)
|
||||
* - On close: revoke the URL so the blob is GC'd
|
||||
*/
|
||||
export function PdfPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
payload,
|
||||
filename,
|
||||
title = 'Preview report',
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [blob, setBlob] = useState<Blob | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-fetch the preview whenever the modal opens (or the payload
|
||||
// changes while open). Revoke the previous object URL inside the
|
||||
// same effect so we never leak.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
let currentUrl: string | null = null;
|
||||
|
||||
async function loadPreview() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
const portId = await resolvePortIdFromSlug(slug);
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/v1/reports/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Preview failed (${res.status})`);
|
||||
}
|
||||
const b = await res.blob();
|
||||
if (cancelled) return;
|
||||
currentUrl = URL.createObjectURL(b);
|
||||
setBlob(b);
|
||||
setUrl(currentUrl);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Preview failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||
};
|
||||
}, [open, payload]);
|
||||
|
||||
// Belt-and-braces revoke on unmount in case the modal closes
|
||||
// mid-fetch and the effect's cleanup didn't capture the URL yet.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
},
|
||||
[url],
|
||||
);
|
||||
|
||||
function handleDownload() {
|
||||
if (!blob) return;
|
||||
triggerBlobDownload(blob, filename);
|
||||
toast.success('Report downloaded');
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl w-[95vw] h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Inline preview of the rendered PDF. Download to save the file or close to go back and
|
||||
tweak the configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden rounded-md border bg-muted/20">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Rendering preview…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<div className="text-sm font-medium text-destructive">Preview failed</div>
|
||||
<div className="font-mono text-xs text-muted-foreground break-all">{error}</div>
|
||||
</div>
|
||||
) : url ? (
|
||||
<iframe
|
||||
key={url}
|
||||
src={url}
|
||||
title="PDF preview"
|
||||
className="h-full w-full"
|
||||
// Sandbox keeps any embedded scripts in the PDF viewer
|
||||
// from reaching our cookies or LocalStorage. allow-
|
||||
// same-origin lets the iframe load the blob URL.
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<X className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleDownload} disabled={!blob || loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user