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:
2026-05-21 20:50:11 +02:00
parent 1cdc2fdc6d
commit 5a9b5f687f
3 changed files with 231 additions and 4 deletions

View File

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

View File

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

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