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

338 lines
13 KiB
TypeScript
Raw Normal View History

feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +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): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
PDF_DASHBOARD_WIDGETS,
PDF_DASHBOARD_CATEGORY_LABELS,
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
type PdfDashboardWidgetId,
type PdfDashboardWidgetCategory,
} from '@/lib/services/dashboard-report-widgets';
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
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): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
/**
* Local-timezone YYYY-MM-DD formatter. We deliberately avoid
* `toISOString().slice(0,10)` because it rolls through UTC and would
* land on the previous day for any rep east of GMT after ~14:00 local.
*/
function toIsoLocal(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
/**
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
* pick which sections to include + set a custom title. Saved-template
* support lands in Phase C; for now, the dialog defaults all widgets
* checked + the current date in the title.
*
* Permission-gated client-side on `reports.export`; the server route
* re-checks via withPermission so a tampered client can't bypass.
*/
export function ExportDashboardPdfButton({ className }: { className?: string } = {}) {
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
const { can } = usePermissions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`);
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
);
// Default report window = last 30 days. Many of the new widgets
// (period cohorts, occupancy timeline) require the window;
// populating with sensible defaults means the rep gets a useful
// report on first export without picking dates.
const today = new Date();
const last30 = new Date(today);
last30.setDate(last30.getDate() - 30);
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
const [dateTo, setDateTo] = useState(toIsoLocal(today));
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
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);
// 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() || 'Report',
config: {
kind: 'dashboard' as const,
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
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
}),
[title, selected, dateFrom, dateTo],
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
);
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
if (!can('reports', 'export')) return null;
function toggle(id: PdfDashboardWidgetId) {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
async function handleExport() {
if (selected.length === 0) {
toast.error('Pick at least one section to include.');
return;
}
setLoading(true);
try {
// FormData isn't required (this is a JSON body), but we DO need
// to forward the X-Port-Id header so the server-side resolver
// knows which port's data to use. apiFetch is JSON-only and
// doesn't expose the raw response body; we need the buffer here
// so do a raw fetch with the same header convention.
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() || 'Report',
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
config: {
kind: 'dashboard',
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Export failed (${res.status})`);
}
const blob = await res.blob();
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
triggerBlobDownload(blob, filename);
toast.success('Report downloaded');
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
} finally {
setLoading(false);
}
}
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
title="Export dashboard as PDF"
aria-label="Export dashboard as PDF"
className={cn('h-9 w-9 p-0 text-muted-foreground hover:text-foreground', className)}
>
<FileDown className="h-4 w-4" aria-hidden />
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export dashboard as PDF</DialogTitle>
<DialogDescription>
Pick which sections to include and set a title. The PDF inherits the active
port&apos;s logo and primary color.
</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="dashboard"
currentConfig={{ widgetIds: selected }}
onApply={(t: SavedTemplate) => {
const cfg = t.config as { widgetIds?: string[] };
if (Array.isArray(cfg.widgetIds)) {
setSelected(
cfg.widgetIds.filter((id): id is PdfDashboardWidgetId =>
PDF_DASHBOARD_WIDGETS.some((w) => w.id === id),
),
);
}
if (t.name) setTitle(t.name);
}}
/>
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
<div className="space-y-1">
<Label htmlFor="export-title">Title</Label>
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
{/* Date-range filter. Drives every time-period section
(new clients in window, berths sold in window, occupancy
timeline, etc.). Defaulted to the last 30 days so a
first-time export already has a sensible window without
the rep configuring anything. Sections that don't
require a window (KPIs, current pipeline funnel, etc.)
ignore it. */}
<div className="space-y-1">
<Label>Report window</Label>
<div className="flex flex-wrap items-center gap-2">
<DatePicker
id="export-date-from"
value={dateFrom}
onChange={setDateFrom}
placeholder="Start"
size="sm"
className="w-[150px]"
/>
<span className="text-xs text-muted-foreground"></span>
<DatePicker
id="export-date-to"
value={dateTo}
onChange={setDateTo}
placeholder="End"
size="sm"
className="w-[150px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 30);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 30 days
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 90);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 90 days
</Button>
</div>
<p className="text-xs text-muted-foreground">
Drives time-period sections (new clients, berths sold, occupancy timeline, etc.).
Sections marked &ldquo;needs date range&rdquo; only render when both dates are set.
</p>
</div>
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
<div className="space-y-2">
<Label>Sections</Label>
{/* Grouped checkbox list. Each widget knows its own
category; we render the categories in PDF_DASHBOARD_-
CATEGORY_LABELS' declared order so charts surface
before tables surface before period cohorts. */}
<div className="max-h-[50vh] space-y-3 overflow-y-auto rounded-md border p-2">
{(
Object.entries(PDF_DASHBOARD_CATEGORY_LABELS) as Array<
[PdfDashboardWidgetCategory, string]
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
>
).map(([category, label]) => {
const items = PDF_DASHBOARD_WIDGETS.filter((w) => w.category === category);
if (items.length === 0) return null;
return (
<div key={category} className="space-y-1">
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="space-y-0.5">
{items.map((w) => (
<label
key={w.id}
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
>
<Checkbox
checked={selected.includes(w.id)}
onCheckedChange={() => toggle(w.id)}
aria-label={w.label}
/>
<div className="text-sm leading-tight">
<div className="flex items-center gap-1.5">
<span className="font-medium">{w.label}</span>
{w.isChart ? (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-primary">
chart
</span>
) : null}
{w.requiresPeriod ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-800">
needs date range
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">{w.description}</div>
</div>
</label>
))}
</div>
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
</div>
);
})}
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
</div>
</div>
</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 || selected.length === 0}
>
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
Preview
</Button>
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
{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, '_') || 'report'}.pdf`}
title={`Preview: ${title.trim() || 'Report'}`}
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
/>
) : null}
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
</>
);
}