feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1 in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial) remain deferred per the gap audit at the bottom of that doc. Highlights: - Sales performance report: 7 KPI tiles, pipeline funnel + stage velocity + win-rate-over-time + source conversion + rep leaderboard charts, deal-heat section, 5 detail tables, stage / lead-cat / outcome filters. - Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy churn, tenure histogram, signing box plot, occupancy by area, docs in pipeline), 4 tables. Module-OFF banner when tenancies disabled. - Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths, tenancies), column-whitelist composer, date filter, CSV download, save-as-template. Registry-only extension path for the remaining 6 entities documented at src/lib/reports/custom/registry.ts. - Templates: load / modify / save / save-as on Sales / Operational / Custom. ?templateId= URL deep-link hydration via useRef guard. Active-template badge clears when the user drives view-state via wrapped setters; raw setters used on template apply so the badge survives. - Scheduled runs: BullMQ poll fires due schedules, mints report_runs, renders, optionally emails. Recipients optional (zero-recipient schedules archive without sending). PDF-only output for v1. Schedule dialog re-mounts via key prop on schedule.id transitions to avoid setState-in-effect reset patterns. - Server-side PDF endpoint + shared payload renderer (lib/pdf/reports/payload-report.tsx) so client + scheduler share one rendering path. - Shared currency formatter (lib/reports/format-currency.ts) consolidates 5 duplicated formatMoney helpers; fixes hardcoded 'USD' in detail tables; pre-formats money rows so PDF export (which strips column.format callbacks at the JSON boundary) renders consistently with CSV / XLSX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
266
src/lib/pdf/reports/payload-report.tsx
Normal file
266
src/lib/pdf/reports/payload-report.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Document, Image, Page, StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import type { ReportPayload, ReportSection } from '@/lib/reports/types';
|
||||
import type { ReportBranding } from './types';
|
||||
|
||||
/**
|
||||
* Generic payload-driven PDF document. Takes a `ReportPayload` from any
|
||||
* report (Sales / Operational / Custom / future) and renders it in a
|
||||
* branded shell — cover with port logo + title + period + generated-at
|
||||
* stamp, KPI grid below, then one section per ReportSection with a
|
||||
* tabular layout.
|
||||
*
|
||||
* This is the "v2" PDF surface; the legacy per-kind documents
|
||||
* (DashboardReport, ClientListReport, etc.) under this folder remain
|
||||
* for the original `/api/v1/reports/[id]` route and aren't touched.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
payload: ReportPayload;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export function PayloadReportDocument({ payload, branding, generatedAt }: Props) {
|
||||
const styles = makeStyles(branding);
|
||||
return (
|
||||
<Document
|
||||
title={payload.title}
|
||||
author={branding.portName}
|
||||
subject={payload.description ?? `${branding.portName} report`}
|
||||
creator="Port Nimara CRM"
|
||||
producer="Port Nimara CRM"
|
||||
>
|
||||
<Page size="A4" style={styles.page} wrap>
|
||||
{/* Cover header — logo + title + period */}
|
||||
<View style={styles.coverHeader}>
|
||||
{branding.logoUrl ? (
|
||||
<Image src={branding.logoUrl} style={styles.logo} cache />
|
||||
) : (
|
||||
<View style={{ width: 36, height: 36 }} />
|
||||
)}
|
||||
<View style={styles.coverHeaderText}>
|
||||
<Text style={styles.title}>{payload.title}</Text>
|
||||
{payload.description ? (
|
||||
<Text style={styles.subtitle}>{payload.description}</Text>
|
||||
) : null}
|
||||
<Text style={styles.period}>
|
||||
{formatDate(payload.range.from)} – {formatDate(payload.range.to)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* KPI grid — three per row */}
|
||||
{payload.kpis.length > 0 ? (
|
||||
<View style={styles.kpiGrid}>
|
||||
{payload.kpis.map((kpi, i) => (
|
||||
<View key={i} style={styles.kpiTile}>
|
||||
<Text style={styles.kpiLabel}>{String(kpi.label).toUpperCase()}</Text>
|
||||
<Text style={styles.kpiValue}>{String(kpi.value)}</Text>
|
||||
{kpi.hint ? <Text style={styles.kpiHint}>{kpi.hint}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Sections */}
|
||||
{payload.sections.map((section, i) => (
|
||||
<SectionBlock key={i} section={section} styles={styles} />
|
||||
))}
|
||||
|
||||
{/* Footer (fixed across pages) */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerLeft}>{branding.portName}</Text>
|
||||
<Text style={styles.footerRight}>Generated {formatDateTime(generatedAt)}</Text>
|
||||
<Text
|
||||
style={styles.footerCenter}
|
||||
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
|
||||
fixed
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBlock({
|
||||
section,
|
||||
styles,
|
||||
}: {
|
||||
section: ReportSection;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.section} wrap>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
{section.rows.length === 0 ? (
|
||||
<Text style={styles.empty}>No data in this section.</Text>
|
||||
) : (
|
||||
<View style={styles.table}>
|
||||
{/* Header */}
|
||||
<View style={[styles.tableRow, styles.tableHeader]}>
|
||||
{section.columns.map((col, i) => {
|
||||
const cellStyle =
|
||||
col.align === 'right'
|
||||
? [styles.tableCell, styles.tableHeaderCell, styles.tableCellRight]
|
||||
: [styles.tableCell, styles.tableHeaderCell];
|
||||
return (
|
||||
<Text key={i} style={cellStyle}>
|
||||
{col.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{/* Body */}
|
||||
{section.rows.map((row, ri) => {
|
||||
const rowStyle =
|
||||
ri % 2 === 1 ? [styles.tableRow, styles.tableRowZebra] : styles.tableRow;
|
||||
return (
|
||||
<View key={ri} style={rowStyle}>
|
||||
{section.columns.map((col, ci) => {
|
||||
const v = row[col.key];
|
||||
const text = col.format ? col.format(v) : formatPlain(v);
|
||||
const cellStyle =
|
||||
col.align === 'right'
|
||||
? [styles.tableCell, styles.tableCellRight]
|
||||
: styles.tableCell;
|
||||
return (
|
||||
<Text key={ci} style={cellStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlain(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return iso.replace('T', ' ').slice(0, 16) + ' UTC';
|
||||
}
|
||||
|
||||
function makeStyles(branding: ReportBranding) {
|
||||
return StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 56,
|
||||
paddingHorizontal: 36,
|
||||
fontSize: 9.5,
|
||||
fontFamily: 'Helvetica',
|
||||
color: '#1e2844',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
coverHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: branding.primaryColor,
|
||||
paddingBottom: 14,
|
||||
marginBottom: 18,
|
||||
},
|
||||
logo: { width: 36, height: 36, objectFit: 'contain' },
|
||||
coverHeaderText: { flexDirection: 'column', flex: 1 },
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginBottom: 3,
|
||||
},
|
||||
subtitle: { fontSize: 10, color: '#475569', marginBottom: 4 },
|
||||
period: { fontSize: 9, color: '#64748b', fontFamily: 'Helvetica' },
|
||||
kpiGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 18,
|
||||
},
|
||||
kpiTile: {
|
||||
width: '32%',
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#e2e8f0',
|
||||
borderRadius: 3,
|
||||
padding: 8,
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
kpiLabel: {
|
||||
fontSize: 7,
|
||||
color: '#64748b',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 3,
|
||||
},
|
||||
kpiValue: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: '#0f172a',
|
||||
marginBottom: 2,
|
||||
},
|
||||
kpiHint: { fontSize: 7.5, color: '#94a3b8' },
|
||||
section: { marginBottom: 16 },
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginBottom: 6,
|
||||
paddingBottom: 3,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#cbd5e1',
|
||||
},
|
||||
empty: { fontSize: 9, color: '#94a3b8', fontStyle: 'italic', paddingVertical: 6 },
|
||||
table: { borderTopWidth: 0.5, borderTopColor: '#e2e8f0' },
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#e2e8f0',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
tableRowZebra: { backgroundColor: '#f8fafc' },
|
||||
tableHeader: { backgroundColor: branding.primaryColor },
|
||||
tableHeaderCell: { color: '#ffffff', fontFamily: 'Helvetica-Bold', fontSize: 8 },
|
||||
tableCell: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 8.5,
|
||||
color: '#1e293b',
|
||||
},
|
||||
tableCellRight: { textAlign: 'right' },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 36,
|
||||
right: 36,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#e2e8f0',
|
||||
paddingTop: 6,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerLeft: { fontSize: 8, color: '#64748b' },
|
||||
footerRight: { fontSize: 8, color: '#94a3b8' },
|
||||
footerCenter: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 6,
|
||||
textAlign: 'center',
|
||||
fontSize: 8,
|
||||
color: '#64748b',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user