From 5a9b5f687fccb3d60a1dace6a17fc106c97db457 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 20:50:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(reports):=20PDF=20preview=20modal=20(phase?= =?UTF-8?q?=20D=20=E2=80=94=20feature=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- .../reports/export-dashboard-pdf-button.tsx | 34 +++- .../reports/export-list-pdf-button.tsx | 27 ++- src/components/reports/pdf-preview-modal.tsx | 174 ++++++++++++++++++ 3 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 src/components/reports/pdf-preview-modal.tsx diff --git a/src/components/reports/export-dashboard-pdf-button.tsx b/src/components/reports/export-dashboard-pdf-button.tsx index 6d1c87ef..fa913733 100644 --- a/src/components/reports/export-dashboard-pdf-button.tsx +++ b/src/components/reports/export-dashboard-pdf-button.tsx @@ -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() { + +