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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FileDown, Loader2 } from 'lucide-react';
|
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -24,6 +24,7 @@ import { triggerBlobDownload } from '@/lib/utils/download';
|
|||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||||
|
import { PdfPreviewModal } from './pdf-preview-modal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||||||
@@ -44,6 +45,18 @@ export function ExportDashboardPdfButton() {
|
|||||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
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;
|
if (!can('reports', 'export')) return null;
|
||||||
|
|
||||||
@@ -159,6 +172,14 @@ export function ExportDashboardPdfButton() {
|
|||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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}>
|
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||||
@@ -170,6 +191,15 @@ export function ExportDashboardPdfButton() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FileDown, Loader2 } from 'lucide-react';
|
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,6 +20,7 @@ import { triggerBlobDownload } from '@/lib/utils/download';
|
|||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||||
|
import { PdfPreviewModal } from './pdf-preview-modal';
|
||||||
|
|
||||||
type ListKind = 'clients' | 'berths' | 'interests';
|
type ListKind = 'clients' | 'berths' | 'interests';
|
||||||
|
|
||||||
@@ -55,6 +56,15 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
|
|||||||
);
|
);
|
||||||
const [includeArchived, setIncludeArchived] = useState(false);
|
const [includeArchived, setIncludeArchived] = useState(false);
|
||||||
const [loading, setLoading] = 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;
|
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}>
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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}>
|
<Button onClick={handleExport} disabled={loading}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
<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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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