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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user