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