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>
This commit is contained in:
77
src/lib/pdf/reports/branded-document.tsx
Normal file
77
src/lib/pdf/reports/branded-document.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Document, Page, View, Text, Image } from '@react-pdf/renderer';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { ReportBranding } from './types';
|
||||
|
||||
interface BrandedReportPageProps {
|
||||
branding: ReportBranding;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** ISO date string rendered into the footer next to the port name. */
|
||||
generatedAt: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-document wrapper used by every report kind. Renders one or
|
||||
* more pages; the outer `<Document>` carries metadata that lands in
|
||||
* the PDF's Document Information dictionary (title shows in the
|
||||
* macOS Quick Look filename strip and Adobe Reader's window chrome).
|
||||
*
|
||||
* Page numbering uses react-pdf's `render={({ pageNumber, totalPages })...}`
|
||||
* pattern so the footer numbers itself without us computing pages
|
||||
* ahead of time. The render-prop is the one place in this file
|
||||
* where we can't avoid mixing layout with data.
|
||||
*/
|
||||
export function BrandedReportDocument({
|
||||
branding,
|
||||
title,
|
||||
subtitle,
|
||||
generatedAt,
|
||||
children,
|
||||
}: BrandedReportPageProps) {
|
||||
const styles = makeReportStyles(branding);
|
||||
return (
|
||||
<Document
|
||||
title={title}
|
||||
author={branding.portName}
|
||||
subject={subtitle ?? `${branding.portName} report`}
|
||||
creator="Port Nimara CRM"
|
||||
producer="Port Nimara CRM"
|
||||
>
|
||||
<Page size="A4" style={styles.page} wrap>
|
||||
{/* Header — logo + title + subtitle. Re-renders inside each
|
||||
page via `fixed` would duplicate the brand bar; instead we
|
||||
keep it as a non-fixed element so it lives at the very top
|
||||
of the first content page. Footer is `fixed` (bottom of
|
||||
every page). */}
|
||||
<View style={styles.header}>
|
||||
{branding.logoUrl ? (
|
||||
<Image src={branding.logoUrl} style={styles.logo} cache />
|
||||
) : (
|
||||
// Empty placeholder keeps the title baseline stable when
|
||||
// no logo is configured. Width matches the logo so the
|
||||
// text starts in the same X coordinate either way.
|
||||
<View style={{ width: 28, height: 28 }} />
|
||||
)}
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{children}
|
||||
|
||||
{/* Footer is `fixed` so it lands on every page; the page
|
||||
number renderer runs once per page (the only piece of
|
||||
this document that knows page count). */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text>{branding.portName}</Text>
|
||||
<Text>Generated {new Date(generatedAt).toLocaleString('en-GB')}</Text>
|
||||
<Text render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`} />
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
250
src/lib/pdf/reports/dashboard-report.tsx
Normal file
250
src/lib/pdf/reports/dashboard-report.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { BrandedReportDocument } from './branded-document';
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { ReportBranding, DashboardReportConfig } from './types';
|
||||
|
||||
/**
|
||||
* Data shape consumed by the dashboard report. Caller (the route
|
||||
* handler) is responsible for fetching the dashboard service's
|
||||
* outputs and packing them into this struct. Keeps the React-PDF
|
||||
* tree pure — no DB calls inside the document tree.
|
||||
*/
|
||||
export interface DashboardReportData {
|
||||
kpis?: {
|
||||
totalClients: number;
|
||||
activeInterests: number;
|
||||
pipelineValue: number;
|
||||
pipelineValueCurrency: string;
|
||||
occupancyRate: number;
|
||||
};
|
||||
pipelineCounts?: Array<{ stage: string; count: number }>;
|
||||
berthStatus?: {
|
||||
available: number;
|
||||
underOffer: number;
|
||||
maintenance: number;
|
||||
sold: number;
|
||||
total: number;
|
||||
};
|
||||
sourceConversion?: Array<{
|
||||
source: string;
|
||||
total: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
conversionRate: number;
|
||||
}>;
|
||||
hotDeals?: Array<{
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
mooringNumber: string | null;
|
||||
stage: string;
|
||||
lastContact: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DashboardReportProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
config: DashboardReportConfig;
|
||||
data: DashboardReportData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary report. The rep picks which widgets to include in
|
||||
* `config.widgetIds`; this template renders sections only for widgets
|
||||
* present in both the config AND the supplied `data` payload (route
|
||||
* handler is responsible for skipping fetches for unselected widgets).
|
||||
*
|
||||
* Chart-style widgets render as tables here (counts, percentages,
|
||||
* cohort breakdowns). The deliberate choice trades a chart's at-a-
|
||||
* glance shape for the actual numbers — a printed report is for
|
||||
* later reference / sharing, not in-the-moment dashboard scanning,
|
||||
* and the table format is fully accessible to screen readers and
|
||||
* holds up if the PDF is OCR-scanned downstream.
|
||||
*/
|
||||
export function DashboardReport({
|
||||
title,
|
||||
subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config,
|
||||
data,
|
||||
}: DashboardReportProps) {
|
||||
const styles = makeReportStyles(branding);
|
||||
const include = (id: string) => config.widgetIds.includes(id);
|
||||
const dateRangeLine =
|
||||
config.dateFrom || config.dateTo
|
||||
? `${config.dateFrom ?? 'open'} → ${config.dateTo ?? 'today'}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<BrandedReportDocument
|
||||
branding={branding}
|
||||
title={title}
|
||||
subtitle={subtitle ?? `Dashboard summary${dateRangeLine ? ` · ${dateRangeLine}` : ''}`}
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
{include('kpi_overview') && data.kpis ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Key metrics</Text>
|
||||
<View style={styles.kpiGrid}>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Total clients</Text>
|
||||
<Text style={styles.kpiValue}>{data.kpis.totalClients.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Active interests</Text>
|
||||
<Text style={styles.kpiValue}>{data.kpis.activeInterests.toLocaleString()}</Text>
|
||||
</View>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Pipeline value</Text>
|
||||
<Text style={styles.kpiValue}>
|
||||
{formatCurrency(String(data.kpis.pipelineValue), data.kpis.pipelineValueCurrency, {
|
||||
maxFractionDigits: 0,
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.kpiSubvalue}>Sum of primary-berth prices, active deals only</Text>
|
||||
</View>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Occupancy</Text>
|
||||
<Text style={styles.kpiValue}>{data.kpis.occupancyRate.toFixed(1)}%</Text>
|
||||
<Text style={styles.kpiSubvalue}>Sold berths / total</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('pipeline_funnel') && data.pipelineCounts ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
|
||||
<Text style={styles.sectionSubtitle}>Active interests grouped by pipeline stage.</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Stage', 'Count']}
|
||||
widths={[70, 30]}
|
||||
rows={data.pipelineCounts.map((row) => [stageLabel(row.stage), String(row.count)])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('berth_status') && data.berthStatus ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Berth status</Text>
|
||||
<Text style={styles.sectionSubtitle}>Current distribution across the marina.</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Status', 'Count', '% of total']}
|
||||
widths={[50, 25, 25]}
|
||||
rows={[
|
||||
[
|
||||
'Available',
|
||||
String(data.berthStatus.available),
|
||||
pct(data.berthStatus.available, data.berthStatus.total),
|
||||
],
|
||||
[
|
||||
'Under offer',
|
||||
String(data.berthStatus.underOffer),
|
||||
pct(data.berthStatus.underOffer, data.berthStatus.total),
|
||||
],
|
||||
[
|
||||
'Sold',
|
||||
String(data.berthStatus.sold),
|
||||
pct(data.berthStatus.sold, data.berthStatus.total),
|
||||
],
|
||||
[
|
||||
'Maintenance',
|
||||
String(data.berthStatus.maintenance),
|
||||
pct(data.berthStatus.maintenance, data.berthStatus.total),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('source_conversion') && data.sourceConversion ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Source conversion</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Interest counts grouped by lead source, with win rate per source.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Source', 'Total', 'Won', 'Lost', 'Win rate']}
|
||||
widths={[40, 15, 15, 15, 15]}
|
||||
rows={data.sourceConversion.map((row) => [
|
||||
row.source,
|
||||
String(row.total),
|
||||
String(row.won),
|
||||
String(row.lost),
|
||||
`${(row.conversionRate * 100).toFixed(1)}%`,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>Hot deals</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
|
||||
widths={[40, 20, 20, 20]}
|
||||
rows={data.hotDeals.map((d) => [
|
||||
d.clientName ?? '-',
|
||||
d.mooringNumber ?? '-',
|
||||
stageLabel(d.stage),
|
||||
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</BrandedReportDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(n: number, total: number): string {
|
||||
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
|
||||
}
|
||||
|
||||
interface SimpleTableProps {
|
||||
styles: ReturnType<typeof makeReportStyles>;
|
||||
headers: string[];
|
||||
widths: number[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain table primitive used by every chart-style widget section.
|
||||
* Widths are percentages of the container (sum to 100). Header row is
|
||||
* gray; data rows alternate fafafa/white for scannability without the
|
||||
* "spreadsheet" feel.
|
||||
*/
|
||||
function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
|
||||
return (
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
{headers.map((header, i) => (
|
||||
<Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
|
||||
{header}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}>
|
||||
{row.map((cell, i) => (
|
||||
<Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
|
||||
{cell}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
120
src/lib/pdf/reports/render-report.ts
Normal file
120
src/lib/pdf/reports/render-report.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
|
||||
import { DashboardReport, type DashboardReportData } from './dashboard-report';
|
||||
import type {
|
||||
ReportBranding,
|
||||
ReportConfig,
|
||||
ReportRequest,
|
||||
DashboardReportConfig,
|
||||
ClientListReportConfig,
|
||||
BerthListReportConfig,
|
||||
InterestListReportConfig,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Pre-fetched data payloads each report kind needs at render time.
|
||||
* The route handler resolves these (one query per requested section)
|
||||
* BEFORE invoking the renderer so the React-PDF tree stays pure.
|
||||
*/
|
||||
export interface ReportData {
|
||||
dashboard?: DashboardReportData;
|
||||
// Phase B will fill these in.
|
||||
clients?: never;
|
||||
berths?: never;
|
||||
interests?: never;
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
portId: string;
|
||||
request: ReportRequest;
|
||||
data: ReportData;
|
||||
/** Allows the route handler to inject the request id so the audit
|
||||
* trail correlates the generated buffer with the originating call. */
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point that resolves branding, dispatches to the
|
||||
* matching React-PDF document component, and returns the binary
|
||||
* buffer. Caller decides whether to stream the buffer in the
|
||||
* response, archive it to S3, or both.
|
||||
*
|
||||
* Production concerns this handles:
|
||||
* - Branding fetch failure → ValidationError (caller can show a
|
||||
* "configure branding before exporting" message).
|
||||
* - Unknown report kind → falls through to discriminated-union
|
||||
* exhaustiveness check at compile time; runtime guard in the
|
||||
* route schema covers attacker-supplied payloads.
|
||||
* - Render failure → bubbles the underlying React-PDF error to
|
||||
* the caller, which logs it as a SERVICE error tier and surfaces
|
||||
* a generic "PDF generation failed" to the rep.
|
||||
*/
|
||||
export async function renderReport({
|
||||
portId,
|
||||
request,
|
||||
data,
|
||||
generatedAt = new Date().toISOString(),
|
||||
}: RenderArgs): Promise<Buffer> {
|
||||
const branding = await resolveBranding(portId);
|
||||
const element = pickDocument(branding, request, data, generatedAt);
|
||||
// renderToBuffer accepts a React element; cast to satisfy the
|
||||
// library's loose `JSX.Element` typing without widening callers.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return renderToBuffer(element as any) as Promise<Buffer>;
|
||||
}
|
||||
|
||||
async function resolveBranding(portId: string): Promise<ReportBranding> {
|
||||
const portRow = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { name: true },
|
||||
});
|
||||
if (!portRow) throw new NotFoundError('Port');
|
||||
const cfg = await getPortBrandingConfig(portId);
|
||||
return {
|
||||
logoUrl: cfg.logoUrl,
|
||||
primaryColor: cfg.primaryColor,
|
||||
portName: portRow.name,
|
||||
};
|
||||
}
|
||||
|
||||
function pickDocument(
|
||||
branding: ReportBranding,
|
||||
request: ReportRequest,
|
||||
data: ReportData,
|
||||
generatedAt: string,
|
||||
) {
|
||||
const cfg: ReportConfig = request.config;
|
||||
switch (cfg.kind) {
|
||||
case 'dashboard':
|
||||
return createElement(DashboardReport, {
|
||||
title: request.title,
|
||||
subtitle: request.subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config: cfg satisfies DashboardReportConfig,
|
||||
data: data.dashboard ?? {},
|
||||
});
|
||||
case 'clients':
|
||||
case 'berths':
|
||||
case 'interests':
|
||||
// Phase B adds the dispatch + matching component. Surface a
|
||||
// clear error so an early-merged Phase A doesn't silently
|
||||
// render a blank PDF when a rep picks one of these kinds.
|
||||
throw new Error(
|
||||
`Report kind '${(cfg as ClientListReportConfig | BerthListReportConfig | InterestListReportConfig).kind}' not implemented yet (Phase B).`,
|
||||
);
|
||||
default: {
|
||||
// Exhaustiveness check — surfaces a compile error if a new
|
||||
// ReportConfig variant is added without a matching case here.
|
||||
const _exhaustive: never = cfg;
|
||||
throw new Error(`Unsupported report kind: ${(_exhaustive as { kind: string }).kind}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src/lib/pdf/reports/styles.ts
Normal file
188
src/lib/pdf/reports/styles.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { StyleSheet } from '@react-pdf/renderer';
|
||||
|
||||
import type { ReportBranding } from './types';
|
||||
|
||||
/**
|
||||
* Builds a `StyleSheet` keyed off the port's primary color. Used by
|
||||
* every report template so per-port branding propagates without each
|
||||
* template wiring it manually.
|
||||
*
|
||||
* Color contrast is computed against the supplied primary so heading
|
||||
* text on the accent bar reads at AA. We don't try to drive every
|
||||
* surface off the primary — only the accent stripe, headings, and
|
||||
* footer separator. Body copy stays slate-700; surfaces stay white +
|
||||
* a subtle gray. Single primary keeps the report looking intentional
|
||||
* rather than chromatically chaotic.
|
||||
*/
|
||||
export function makeReportStyles(branding: ReportBranding) {
|
||||
// Pick foreground for the accent stripe: 0 / 256 luminance threshold
|
||||
// is loose; report headings are small enough that pure white reads
|
||||
// fine on any reasonable brand color and pure black reads fine on
|
||||
// pale brands.
|
||||
const accentFg = pickReadableForeground(branding.primaryColor);
|
||||
|
||||
return StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 36,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 36,
|
||||
fontSize: 9.5,
|
||||
fontFamily: 'Helvetica',
|
||||
color: '#1f2937',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
paddingBottom: 10,
|
||||
marginBottom: 14,
|
||||
},
|
||||
logo: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
objectFit: 'contain',
|
||||
},
|
||||
headerText: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
color: '#6b7280',
|
||||
},
|
||||
accentBar: {
|
||||
backgroundColor: branding.primaryColor,
|
||||
color: accentFg,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 2,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginTop: 14,
|
||||
marginBottom: 6,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 9,
|
||||
color: '#6b7280',
|
||||
marginBottom: 8,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
tableHeaderCell: {
|
||||
padding: 6,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
fontSize: 9,
|
||||
color: '#374151',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f3f4f6',
|
||||
},
|
||||
tableRowZebra: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f3f4f6',
|
||||
backgroundColor: '#fafafa',
|
||||
},
|
||||
tableCell: {
|
||||
padding: 6,
|
||||
fontSize: 9,
|
||||
color: '#1f2937',
|
||||
},
|
||||
muted: {
|
||||
color: '#6b7280',
|
||||
},
|
||||
kpiGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
kpiCard: {
|
||||
flexGrow: 1,
|
||||
flexBasis: '23%',
|
||||
minWidth: '23%',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
},
|
||||
kpiLabel: {
|
||||
fontSize: 8,
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
kpiValue: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
},
|
||||
kpiSubvalue: {
|
||||
fontSize: 9,
|
||||
color: '#6b7280',
|
||||
marginTop: 2,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
right: 36,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
paddingTop: 8,
|
||||
fontSize: 8,
|
||||
color: '#6b7280',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-color luminance check. Used to pick black or white text on
|
||||
* the accent stripe so the title reads regardless of how dark the
|
||||
* port's brand is. Standard relative-luminance formula; threshold
|
||||
* 0.55 picks white on mid-dark brands.
|
||||
*/
|
||||
function pickReadableForeground(hex: string): string {
|
||||
const cleaned = hex.replace('#', '');
|
||||
if (cleaned.length !== 6) return '#ffffff';
|
||||
const r = parseInt(cleaned.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(cleaned.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(cleaned.slice(4, 6), 16) / 255;
|
||||
const luminance = 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||||
return luminance > 0.55 ? '#0f172a' : '#ffffff';
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number): number {
|
||||
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
65
src/lib/pdf/reports/types.ts
Normal file
65
src/lib/pdf/reports/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared types for the report-PDF pipeline.
|
||||
*
|
||||
* The render entry point (`render-report.ts`) takes a `ReportConfig`
|
||||
* discriminated-union plus the resolved data, then dispatches to the
|
||||
* matching React-PDF document component. Each kind keeps its own
|
||||
* config shape so type-checking surfaces "you forgot to set
|
||||
* widgetIds for the dashboard report" inline.
|
||||
*/
|
||||
|
||||
/** Per-port branding pulled from `port_branding`. Single primary
|
||||
* color drives the headings / footer accent; logo is rendered into
|
||||
* the page header. */
|
||||
export interface ReportBranding {
|
||||
/** Public URL to the port's logo (square PNG). Null falls back to
|
||||
* the bundled Port Nimara circular logo. */
|
||||
logoUrl: string | null;
|
||||
/** Hex string, e.g. "#0F4C81". Drives heading + footer accent. */
|
||||
primaryColor: string;
|
||||
/** Used in the header and footer attribution. */
|
||||
portName: string;
|
||||
}
|
||||
|
||||
export interface DashboardReportConfig {
|
||||
kind: 'dashboard';
|
||||
/** Widget ids to include — keyed against `widget-registry.ts`. */
|
||||
widgetIds: string[];
|
||||
/** Date range applied to dashboard data fetches. */
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface ClientListReportConfig {
|
||||
kind: 'clients';
|
||||
/** Optional column override; defaults to a canonical set. */
|
||||
columns?: string[];
|
||||
/** Optional filter snapshot — same shape as `/api/v1/clients`. */
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BerthListReportConfig {
|
||||
kind: 'berths';
|
||||
columns?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InterestListReportConfig {
|
||||
kind: 'interests';
|
||||
columns?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ReportConfig =
|
||||
| DashboardReportConfig
|
||||
| ClientListReportConfig
|
||||
| BerthListReportConfig
|
||||
| InterestListReportConfig;
|
||||
|
||||
export interface ReportRequest {
|
||||
/** Free-text title shown on the cover / page header. */
|
||||
title: string;
|
||||
/** Optional subtitle (e.g. date range, "Quarterly review"). */
|
||||
subtitle?: string;
|
||||
config: ReportConfig;
|
||||
}
|
||||
Reference in New Issue
Block a user