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:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View 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',
},
});
}