Files
pn-new-crm/src/components/reports/export-list-pdf-button.tsx

183 lines
6.3 KiB
TypeScript
Raw Normal View History

feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
'use client';
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>
2026-05-21 20:50:11 +02:00
import { useMemo, useState } from 'react';
import { Eye, FileDown, Loader2 } from 'lucide-react';
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { triggerBlobDownload } from '@/lib/utils/download';
import { usePermissions } from '@/hooks/use-permissions';
import { resolvePortIdFromSlug } from '@/lib/api/client';
feat(reports): saved-template store + CRUD + dialog integration (phase C) Saves rep-configured export setups so a "Monthly board report" or "Weekly pipeline review" template only has to be assembled once. Schema (migration 0079_report_templates.sql + drizzle entry): - report_templates: id, port_id, kind, name, description, config (jsonb), created_by, created_at, updated_at. - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so Port A and Port B can both have "Quarterly review" without colliding, and two different KINDS in the same port can share a name (a clients "Quarterly review" + an interests "Quarterly review" coexist). - port_id FK cascades on delete; templates evaporate with the parent port. No cross-port enumeration risk since every query filters by port_id. Service (src/lib/services/report-templates.service.ts): - createReportTemplate / listReportTemplates / getReportTemplate / updateReportTemplate / deleteReportTemplate. - Audit-logs every write with old/new values for the rename case. - Surfaces sibling-name collisions as ConflictError with a rep-readable message ('A "Monthly board report" template already exists for the dashboard kind'). Routes: - GET /api/v1/reports/templates?kind=clients - POST /api/v1/reports/templates - GET /api/v1/reports/templates/[id] - PATCH /api/v1/reports/templates/[id] - DELETE /api/v1/reports/templates/[id] All gated on `reports.export` — same permission as generating reports lets the rep manage the templates that drive them. POST cross-validates that `body.kind === body.config.kind` so a rep can't sneak a dashboard config into a clients template and confuse the rendering path at use time. UI: - SavedTemplatesPicker reusable component — dropdown of templates for this port + kind, inline "Save as template" toggle that expands to a name input + Save button, delete button next to the picker once a template is selected. - Wired into both ExportDashboardPdfButton + ExportListPdfButton. Applying a saved template hydrates the dialog's form (selected widgets / filters / title) from the saved config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
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>
2026-05-21 20:50:11 +02:00
import { PdfPreviewModal } from './pdf-preview-modal';
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
type ListKind = 'clients' | 'berths' | 'interests';
interface Props {
kind: ListKind;
/** Label shown on the trigger button (e.g. "Export PDF"). */
buttonLabel?: string;
/** Default title pre-populated in the dialog. */
defaultTitle?: string;
}
const KIND_LABEL: Record<ListKind, string> = {
clients: 'clients',
berths: 'berths',
interests: 'interests',
};
/**
* Generic list-report export button. Renders a small dialog with
* a title input + "include archived" toggle, then POSTs to the
* report-generate endpoint. The kind discriminator picks the
* matching server-side data resolver and React-PDF template.
*
* Permission-gated client-side on `reports.export`; the server
* route enforces the same.
*/
export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultTitle }: Props) {
const { can } = usePermissions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(
defaultTitle ??
`${KIND_LABEL[kind].charAt(0).toUpperCase() + KIND_LABEL[kind].slice(1)} report - ${new Date().toLocaleDateString('en-GB')}`,
);
const [includeArchived, setIncludeArchived] = useState(false);
const [loading, setLoading] = useState(false);
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>
2026-05-21 20:50:11 +02:00
const [previewOpen, setPreviewOpen] = useState(false);
const previewPayload = useMemo(
() => ({
title: title.trim() || `${kind} report`,
config: { kind, filters: { includeArchived } },
}),
[title, kind, includeArchived],
);
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
if (!can('reports', 'export')) return null;
async function handleExport() {
setLoading(true);
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({
title: title.trim() || `${kind} report`,
config: {
kind,
filters: { includeArchived },
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Export failed (${res.status})`);
}
const blob = await res.blob();
triggerBlobDownload(blob, `${title.trim().replace(/[\\/]/g, '_')}.pdf`);
toast.success('Report downloaded');
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
} finally {
setLoading(false);
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
{buttonLabel}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export {KIND_LABEL[kind]} as PDF</DialogTitle>
<DialogDescription>
The PDF inherits the active port&apos;s logo and primary color. Up to 1 000 rows are
exported; for larger exports use CSV.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
feat(reports): saved-template store + CRUD + dialog integration (phase C) Saves rep-configured export setups so a "Monthly board report" or "Weekly pipeline review" template only has to be assembled once. Schema (migration 0079_report_templates.sql + drizzle entry): - report_templates: id, port_id, kind, name, description, config (jsonb), created_by, created_at, updated_at. - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so Port A and Port B can both have "Quarterly review" without colliding, and two different KINDS in the same port can share a name (a clients "Quarterly review" + an interests "Quarterly review" coexist). - port_id FK cascades on delete; templates evaporate with the parent port. No cross-port enumeration risk since every query filters by port_id. Service (src/lib/services/report-templates.service.ts): - createReportTemplate / listReportTemplates / getReportTemplate / updateReportTemplate / deleteReportTemplate. - Audit-logs every write with old/new values for the rename case. - Surfaces sibling-name collisions as ConflictError with a rep-readable message ('A "Monthly board report" template already exists for the dashboard kind'). Routes: - GET /api/v1/reports/templates?kind=clients - POST /api/v1/reports/templates - GET /api/v1/reports/templates/[id] - PATCH /api/v1/reports/templates/[id] - DELETE /api/v1/reports/templates/[id] All gated on `reports.export` — same permission as generating reports lets the rep manage the templates that drive them. POST cross-validates that `body.kind === body.config.kind` so a rep can't sneak a dashboard config into a clients template and confuse the rendering path at use time. UI: - SavedTemplatesPicker reusable component — dropdown of templates for this port + kind, inline "Save as template" toggle that expands to a name input + Save button, delete button next to the picker once a template is selected. - Wired into both ExportDashboardPdfButton + ExportListPdfButton. Applying a saved template hydrates the dialog's form (selected widgets / filters / title) from the saved config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
<SavedTemplatesPicker
kind={kind}
currentConfig={{ filters: { includeArchived } }}
onApply={(t: SavedTemplate) => {
const cfg = t.config as { filters?: { includeArchived?: boolean } };
if (cfg.filters?.includeArchived !== undefined) {
setIncludeArchived(Boolean(cfg.filters.includeArchived));
}
if (t.name) setTitle(t.name);
}}
/>
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
<div className="space-y-1">
<Label htmlFor={`export-title-${kind}`}>Title</Label>
<Input
id={`export-title-${kind}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={includeArchived}
onCheckedChange={(c) => setIncludeArchived(Boolean(c))}
aria-label="Include archived"
/>
Include archived
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
Cancel
</Button>
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>
2026-05-21 20:50:11 +02:00
<Button variant="outline" onClick={() => setPreviewOpen(true)} disabled={loading}>
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
Preview
</Button>
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
<Button onClick={handleExport} disabled={loading}>
{loading ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
)}
Download PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
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>
2026-05-21 20:50:11 +02:00
{previewOpen ? (
<PdfPreviewModal
open
onOpenChange={setPreviewOpen}
payload={previewPayload}
filename={`${title.trim().replace(/[\\/]/g, '_') || `${kind}-report`}.pdf`}
title={`Preview: ${title.trim() || `${kind} report`}`}
/>
) : null}
feat(reports): client / berth / interest list-export PDF reports (phase B) Extends the report exporter with three list-style report kinds — clients, berths, interests. Each shares the BrandedReportDocument layout + the new ReportTable primitive (zebra-striped rows, proportional widths, no-break rows to keep records together across page boundaries). Data fetchers in `src/lib/services/list-report-data.service.ts`: - resolveClientReportData: clients table joined to per-client primary email + phone via DISTINCT-style subqueries (matches the canonical listClients ordering: is_primary DESC, created_at DESC per channel). - resolveBerthReportData: berths table, default sort by mooring number for printed familiarity. - resolveInterestReportData: interests left-joined to clients + primary berth, sort by updatedAt desc. All three cap at 1 000 rows per export with a clear "Showing top N of <total>" notice rendered when the cap is hit. Above that, the PDF becomes unreadable (hundreds of pages); reps wanting larger exports use CSV. Route schema widened to a 4-arm discriminated union; the dispatch switch in render-report.ts uses `satisfies` for compile-time variant narrowing and a `_exhaustive: never` check at the bottom. UI: each list page (BerthList, ClientList, InterestList) gains an ExportListPdfButton next to the existing ColumnPicker. Permission- gated client-side on reports.export; server route re-enforces. Tests: 3 new render fixtures (1 per kind), all hit the same %PDF-magic + byte-length assertions. Total render tests now 6/6; full vitest sweep 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
</>
);
}