Files
pn-new-crm/src/lib/services/dashboard-report-data.service.ts

678 lines
27 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
/**
* Server-side data resolver for the dashboard PDF report.
*
* Each section is gated on its widget id being present in
* `config.widgetIds`, so a report that only includes the pipeline
* funnel runs ONE query instead of the full dashboard panel. Keeps
* cold-call latency low even when the actual port has hundreds of
* berths.
*
* Lives in its own file (not inside dashboard.service.ts) so the
* report-builder concerns - what widget ids map to what fetcher,
* which fields the PDF shape requires - stay scoped to the
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
* report-side surface, not the dashboard UI.
*/
import { and, count, desc, eq, gte, lte, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { documents } from '@/lib/db/schema/documents';
import { reminders } from '@/lib/db/schema/operations';
import { payments } from '@/lib/db/schema/pipeline';
import { auditLogs } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { computeDealHealth } from './deal-health';
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 {
getKpis,
getPipelineCounts,
getBerthStatusDistribution,
getHotDeals,
getRevenueForecast,
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
getSourceConversion,
} from './dashboard.service';
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
export interface DashboardReportWindow {
/** Optional inclusive lower bound (YYYY-MM-DD). */
dateFrom?: string;
/** Optional inclusive upper bound (YYYY-MM-DD). */
dateTo?: string;
}
function parseWindow(window: DashboardReportWindow | undefined): {
from: Date | null;
to: Date | null;
} {
if (!window) return { from: null, to: null };
// Resolve the window into UTC date objects. dateFrom anchors to
// start-of-day; dateTo anchors to end-of-day so the inclusive upper
// bound covers the whole calendar day.
const from = window.dateFrom ? new Date(`${window.dateFrom}T00:00:00.000Z`) : null;
const to = window.dateTo ? new Date(`${window.dateTo}T23:59:59.999Z`) : null;
return {
from: from && !Number.isNaN(from.getTime()) ? from : null,
to: to && !Number.isNaN(to.getTime()) ? to : null,
};
}
// Pure data/types now live in `dashboard-report-widgets.ts` so the
// client-side export button can import them without dragging this
// file's DB-touching imports into the browser bundle. Re-exported
// here so existing consumers keep working.
export {
PDF_DASHBOARD_WIDGET_IDS,
PDF_DASHBOARD_WIDGETS,
PDF_DASHBOARD_CATEGORY_LABELS,
type PdfDashboardWidgetId,
type PdfDashboardWidgetOption,
type PdfDashboardWidgetCategory,
} from './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
/**
* Widget ids whose data resolver isn't fully wired yet. Today the
* exporter accepts them (the UI surfaces the choice) but renders a
* "Coming soon" footnote in the PDF. Resolvers ship iteratively;
* each one moves out of this set when its branch lands below.
*/
/** All 16 widget resolvers now ship set is intentionally empty.
* Kept as a non-empty `Set<string>` type to make adding NEW widgets
* (whose resolvers will lag behind their catalog entry) drop-in. */
const PENDING_RESOLVER_IDS = new Set<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
export async function resolveDashboardReportData(
portId: string,
widgetIds: string[],
window?: DashboardReportWindow,
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
): Promise<DashboardReportData> {
const want = new Set(widgetIds);
const data: DashboardReportData = {};
const { from: windowFrom, to: windowTo } = parseWindow(window);
const hasWindow = windowFrom !== null && windowTo !== 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
// ─── KPI / summary ───────────────────────────────────────────────
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 (want.has('kpi_overview')) {
data.kpis = await getKpis(portId);
}
// ─── Pipeline ────────────────────────────────────────────────────
// Chart + table variants share the same underlying data so they
// both pull from `getPipelineCounts()`.
if (want.has('pipeline_funnel') || want.has('pipeline_funnel_chart')) {
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
data.pipelineCounts = await getPipelineCounts(portId);
}
// ─── Berths ──────────────────────────────────────────────────────
if (want.has('berth_status') || want.has('berth_status_donut')) {
data.berthStatus = await getBerthStatusDistribution(portId);
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
}
// ─── Sources ─────────────────────────────────────────────────────
if (want.has('source_conversion') || want.has('source_conversion_chart')) {
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
data.sourceConversion = await getSourceConversion(portId);
}
// ─── Deals ───────────────────────────────────────────────────────
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 (want.has('hot_deals')) {
const deals = await getHotDeals(portId, 5);
data.hotDeals = deals.map((d) => ({
id: d.id,
clientName: d.clientName,
mooringNumber: d.mooringNumber,
stage: d.stage,
lastContact: d.lastContact,
}));
}
// ─── Client country distribution ────────────────────────────────
// Reuses the same query the dashboard widget runs - gives the rep
// a shareholder-friendly "where does our book come from" view.
if (want.has('client_country_distribution')) {
const rows = await db
.select({
country: clients.nationalityIso,
count: count(),
})
.from(clients)
.where(and(eq(clients.portId, portId), sql`${clients.archivedAt} IS NULL`))
.groupBy(clients.nationalityIso)
.orderBy(desc(count()));
data.clientCountryDistribution = rows
.filter((r): r is { country: string; count: number } => r.country !== null)
.map((r) => ({ country: r.country, count: Number(r.count) }));
}
// ─── Recent activity snapshot ────────────────────────────────────
// Compact 20-row audit-log snapshot for the print artefact. Joins
// user_profiles for the actor name so the printed log doesn't show
// raw UUIDs (matches the in-app activity-feed UUID policy).
if (want.has('recent_activity')) {
const rows = await db
.select({
when: auditLogs.createdAt,
action: auditLogs.action,
entityType: auditLogs.entityType,
actorFirstName: userProfiles.firstName,
actorLastName: userProfiles.lastName,
actorDisplayName: userProfiles.displayName,
})
.from(auditLogs)
.leftJoin(userProfiles, eq(userProfiles.userId, auditLogs.userId))
.where(eq(auditLogs.portId, portId))
.orderBy(desc(auditLogs.createdAt))
.limit(20);
data.recentActivity = rows.map((r) => {
const actor =
[r.actorFirstName, r.actorLastName].filter(Boolean).join(' ').trim() ||
r.actorDisplayName ||
null;
return {
when: r.when.toISOString(),
actor,
summary: `${r.action} · ${r.entityType}`,
};
});
}
// ─── Lead source mix (donut) ─────────────────────────────────────
// Distinct from `source_conversion`: this counts active interests
// grouped by source for the donut variant rather than win-rate.
if (want.has('lead_source_donut')) {
const rows = await db
.select({
source: interests.source,
count: count(),
})
.from(interests)
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`))
.groupBy(interests.source)
.orderBy(desc(count()));
data.leadSourceMix = rows.map((r) => ({
source: r.source ?? 'unknown',
count: Number(r.count),
}));
// It's now resolved, drop the pending marker for this one.
PENDING_RESOLVER_IDS.delete('lead_source_donut');
}
// ─── Period cohorts ──────────────────────────────────────────────
// All of the below honour the supplied date window; when the window
// is missing they short-circuit (the export-dialog also flags these
// widgets with a "needs date range" chip).
if (want.has('new_clients_period') && hasWindow) {
const rows = await db
.select({
fullName: clients.fullName,
createdAt: clients.createdAt,
source: clients.source,
})
.from(clients)
.where(
and(
eq(clients.portId, portId),
sql`${clients.archivedAt} IS NULL`,
gte(clients.createdAt, windowFrom),
lte(clients.createdAt, windowTo),
),
)
.orderBy(desc(clients.createdAt))
.limit(50);
data.newClientsInPeriod = rows.map((r) => ({
name: r.fullName,
createdAt: r.createdAt.toISOString(),
source: r.source ?? null,
}));
}
if (want.has('new_interests_period') && hasWindow) {
const rows = await db
.select({
id: interests.id,
clientName: clients.fullName,
stage: interests.pipelineStage,
source: interests.source,
createdAt: interests.createdAt,
})
.from(interests)
.innerJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
eq(interests.portId, portId),
sql`${interests.archivedAt} IS NULL`,
gte(interests.createdAt, windowFrom),
lte(interests.createdAt, windowTo),
),
)
.orderBy(desc(interests.createdAt))
.limit(50);
data.newInterestsInPeriod = rows.map((r) => ({
clientName: r.clientName,
stage: r.stage,
source: r.source ?? null,
createdAt: r.createdAt.toISOString(),
}));
}
if (want.has('berths_sold_period') && hasWindow) {
// Berth-status transitions from audit_logs (entity_type='berth',
// new_value->>'status' = 'Sold'). Each row carries the berth_id;
// join back for the mooring number.
const rows = await db
.select({
berthId: auditLogs.entityId,
when: auditLogs.createdAt,
})
.from(auditLogs)
.where(
and(
eq(auditLogs.portId, portId),
eq(auditLogs.entityType, 'berth'),
sql`${auditLogs.newValue}->>'status' = 'Sold'`,
gte(auditLogs.createdAt, windowFrom),
lte(auditLogs.createdAt, windowTo),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(50);
const ids = rows.map((r) => r.berthId).filter((id): id is string => !!id);
const moorings = new Map<string, string>();
if (ids.length > 0) {
const berthRows = await db
.select({ id: berths.id, mooring: berths.mooringNumber })
.from(berths)
.where(and(eq(berths.portId, portId), sql`${berths.id} = ANY(${ids})`));
for (const b of berthRows) moorings.set(b.id, b.mooring);
}
data.berthsSoldInPeriod = rows.map((r) => ({
mooringNumber: r.berthId ? (moorings.get(r.berthId) ?? '(removed berth)') : '-',
soldAt: r.when.toISOString(),
}));
}
if ((want.has('signed_documents_period') || want.has('contracts_signed_period')) && hasWindow) {
// Signed documents from the documents table. document_type tells
// us if it's an EOI, reservation, or contract; the user picks
// either the broad list or the contract-only subset.
const wantContractsOnly =
want.has('contracts_signed_period') && !want.has('signed_documents_period');
// documents.updatedAt is the most-recent state change — for rows
// with status='completed' this proxies the signed-completed
// moment. A more precise reading would come from documentEvents
// (eventType='completed') but that requires a join + group; we
// can swap to that resolver when fidelity matters.
const rows = await db
.select({
type: documents.documentType,
title: documents.title,
signedAt: documents.updatedAt,
})
.from(documents)
.where(
and(
eq(documents.portId, portId),
eq(documents.status, 'completed'),
...(wantContractsOnly ? [eq(documents.documentType, 'contract')] : []),
gte(documents.updatedAt, windowFrom),
lte(documents.updatedAt, windowTo),
),
)
.orderBy(desc(documents.updatedAt))
.limit(50);
const mapped = rows.map((r) => ({
type: r.type,
title: r.title,
signedAt: r.signedAt.toISOString(),
}));
if (want.has('signed_documents_period')) data.signedDocumentsInPeriod = mapped;
if (want.has('contracts_signed_period'))
data.contractsSignedInPeriod = wantContractsOnly
? mapped
: mapped.filter((m) => m.type === 'contract');
}
if (want.has('deposits_received_period') && hasWindow) {
const rows = await db
.select({
amount: payments.amount,
currency: payments.currency,
paidAt: payments.receivedAt,
clientName: clients.fullName,
})
.from(payments)
.innerJoin(interests, eq(payments.interestId, interests.id))
.innerJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
eq(payments.portId, portId),
eq(payments.paymentType, 'deposit'),
gte(payments.receivedAt, windowFrom),
lte(payments.receivedAt, windowTo),
),
)
.orderBy(desc(payments.receivedAt))
.limit(50);
data.depositsReceivedInPeriod = rows.map((r) => ({
clientName: r.clientName,
amount: Number(r.amount),
currency: r.currency,
paidAt: r.paidAt.toISOString(),
}));
}
// Deal pulse distribution: pulse-tier is computed dynamically in
// the pulse service rather than stored on interests directly, so
// the resolver here would have to walk the pulse rules for every
// active deal. Queue for a follow-up — for now, falls through the
// stubsPending pathway and shows the "Coming soon" footnote.
// ─── Deal pulse distribution ────────────────────────────────────
// The pulse tier is computed dynamically by `computeDealHealth` from
// the interest's date fields + doc status — not stored on the
// interests row. So we fetch the relevant fields for every active
// interest, run the scorer, and bucket by tier. Cheap because the
// scorer is pure synchronous arithmetic.
if (want.has('deal_pulse_distribution')) {
const rows = await db
.select({
pipelineStage: interests.pipelineStage,
outcome: interests.outcome,
archivedAt: interests.archivedAt,
dateFirstContact: interests.dateFirstContact,
dateLastContact: interests.dateLastContact,
dateEoiSent: interests.dateEoiSent,
dateEoiSigned: interests.dateEoiSigned,
dateReservationSigned: interests.dateReservationSigned,
dateContractSent: interests.dateContractSent,
dateContractSigned: interests.dateContractSigned,
dateDepositReceived: interests.dateDepositReceived,
eoiDocStatus: interests.eoiDocStatus,
reservationDocStatus: interests.reservationDocStatus,
contractDocStatus: interests.contractDocStatus,
})
.from(interests)
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`));
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
for (const r of rows) {
const health = computeDealHealth({
pipelineStage: r.pipelineStage ?? 'open',
outcome: r.outcome,
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
dateLastContact: r.dateLastContact ? r.dateLastContact.toISOString() : null,
dateEoiSent: r.dateEoiSent ? r.dateEoiSent.toISOString() : null,
dateEoiSigned: r.dateEoiSigned ? r.dateEoiSigned.toISOString() : null,
dateReservationSigned: r.dateReservationSigned
? r.dateReservationSigned.toISOString()
: null,
dateContractSent: r.dateContractSent ? r.dateContractSent.toISOString() : null,
dateContractSigned: r.dateContractSigned ? r.dateContractSigned.toISOString() : null,
dateDepositReceived: r.dateDepositReceived ? r.dateDepositReceived.toISOString() : null,
eoiDocStatus: r.eoiDocStatus,
reservationDocStatus: r.reservationDocStatus,
contractDocStatus: r.contractDocStatus,
});
buckets[health.pulse] = (buckets[health.pulse] ?? 0) + 1;
}
data.dealPulseDistribution = Object.entries(buckets).map(([tier, c]) => ({
tier,
count: c,
}));
}
// ─── Occupancy timeline ─────────────────────────────────────────
// Daily occupancy rate (% of berths in Sold OR under_offer state)
// over the report window. Resolver: count total berths once, then
// for each day in the window compute occupied = berths whose
// current state is Sold OR under_offer AS OF that day, derived
// from audit_logs status transitions.
//
// For simplicity in this first pass: emit the CURRENT occupancy
// for every day in the window (flat line at the current rate). A
// true history-aware curve needs us to replay audit_logs day by
// day, which we'll wire when the volume justifies the extra pass.
if (want.has('occupancy_timeline_chart') && hasWindow) {
const [{ totalCount = 0 } = {}] = await db
.select({ totalCount: count() })
.from(berths)
.where(eq(berths.portId, portId));
const [{ occCount = 0 } = {}] = await db
.select({ occCount: count() })
.from(berths)
.where(
and(
eq(berths.portId, portId),
sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`,
),
);
const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0;
const series: Array<{ date: string; rate: number }> = [];
const dayMs = 86_400_000;
for (let t = windowFrom.getTime(); t <= windowTo.getTime(); t += dayMs) {
const d = new Date(t);
series.push({ date: d.toISOString().slice(0, 10), rate: currentRate });
}
data.occupancyTimeline = series;
}
// ─── Reminders summary ──────────────────────────────────────────
if (want.has('reminders_summary') && hasWindow) {
// Counts open + completed reminders per assignee over the window
// (createdAt or completedAt falling inside it). Useful as a
// "who's doing what" rollup for shareholder reports.
const rows = await db
.select({
assignee: reminders.assignedTo,
status: reminders.status,
c: count(),
})
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
gte(reminders.createdAt, windowFrom),
lte(reminders.createdAt, windowTo),
),
)
.groupBy(reminders.assignedTo, reminders.status);
// Roll up per-assignee totals (open vs completed).
const byAssignee = new Map<string, { open: number; completed: number; other: number }>();
for (const r of rows) {
const key = r.assignee ?? '(unassigned)';
const bucket = byAssignee.get(key) ?? { open: 0, completed: 0, other: 0 };
if (r.status === 'pending' || r.status === 'snoozed') bucket.open += Number(r.c);
else if (r.status === 'completed') bucket.completed += Number(r.c);
else bucket.other += Number(r.c);
byAssignee.set(key, bucket);
}
// Resolve user-id assignees to display names so the printed
// report doesn't leak UUIDs (matches the activity-feed policy).
const userIds = Array.from(byAssignee.keys()).filter((k) => k !== '(unassigned)');
const profiles = userIds.length
? await db
.select({
userId: userProfiles.userId,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
displayName: userProfiles.displayName,
})
.from(userProfiles)
.where(sql`${userProfiles.userId} = ANY(${userIds})`)
: [];
const nameById = new Map(
profiles.map((p) => [
p.userId,
[p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || '(unknown)',
]),
);
data.remindersSummary = Array.from(byAssignee.entries()).map(([assignee, bucket]) => ({
assignee: assignee === '(unassigned)' ? assignee : (nameById.get(assignee) ?? assignee),
open: bucket.open,
completed: bucket.completed,
}));
}
// ─── Stage conversion rates ─────────────────────────────────────
// Snapshot-style conversion: for each pair of consecutive pipeline
// stages, "% advanced" = downstream count / (downstream + upstream
// count). This is a current-state proxy rather than a true cohort
// funnel (which would need audit-log stage_change events). It tracks
// the shape of the pipeline accurately enough for shareholder
// reporting without the heavier history walk.
if (want.has('stage_conversion_rates')) {
const { PIPELINE_STAGES } = await import('@/lib/constants');
const counts = await getPipelineCounts(portId);
const countByStage = new Map<string, number>(counts.map((c) => [c.stage, c.count]));
const rates: NonNullable<DashboardReportData['stageConversionRates']> = [];
for (let i = 0; i < PIPELINE_STAGES.length - 1; i++) {
const fromStage = PIPELINE_STAGES[i]!;
const toStage = PIPELINE_STAGES[i + 1]!;
const upstream = countByStage.get(fromStage) ?? 0;
const downstream = countByStage.get(toStage) ?? 0;
const total = upstream + downstream;
rates.push({
fromStage,
toStage,
advanced: downstream,
dropped: upstream,
rate: total > 0 ? downstream / total : 0,
});
}
data.stageConversionRates = rates;
}
// ─── Inquiry inbox summary ──────────────────────────────────────
if (want.has('inquiry_inbox_summary') && hasWindow) {
const rows = await db
.select({
triageState: websiteSubmissions.triageState,
kind: websiteSubmissions.kind,
c: count(),
})
.from(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, portId),
gte(websiteSubmissions.receivedAt, windowFrom),
lte(websiteSubmissions.receivedAt, windowTo),
),
)
.groupBy(websiteSubmissions.triageState, websiteSubmissions.kind);
data.inquiryInboxSummary = rows.map((r) => ({
kind: r.kind,
triageState: r.triageState,
count: Number(r.c),
}));
}
// ─── Revenue forecast ───────────────────────────────────────────
if (want.has('revenue_forecast')) {
const forecast = await getRevenueForecast(portId);
data.revenueForecast = {
grossValue: forecast.totalGrossValue,
weightedValue: forecast.totalWeightedValue,
currency: 'EUR',
};
}
// ─── Avg sales cycle ────────────────────────────────────────────
// Days from interest.createdAt → reservation/contract signed event
// (we use updatedAt on `documents` with status='completed' as the
// signed-at proxy, same convention as the signed_documents_period
// resolver above).
if (want.has('avg_sales_cycle')) {
const rows = await db
.select({
openedAt: interests.createdAt,
closedAt: documents.updatedAt,
})
.from(interests)
.innerJoin(documents, eq(documents.interestId, interests.id))
.where(
and(
eq(interests.portId, portId),
eq(documents.documentType, 'contract'),
eq(documents.status, 'completed'),
),
);
if (rows.length === 0) {
data.avgSalesCycle = { sampleSize: 0, medianDays: null, meanDays: null };
} else {
const days = rows
.map((r) =>
Math.max(0, Math.round((r.closedAt.getTime() - r.openedAt.getTime()) / 86_400_000)),
)
.sort((a, b) => a - b);
const mid = Math.floor(days.length / 2);
const median =
days.length === 0
? null
: days.length % 2 === 0
? Math.round(((days[mid - 1] ?? 0) + (days[mid] ?? 0)) / 2)
: (days[mid] ?? null);
const mean = Math.round(days.reduce((s, d) => s + d, 0) / days.length);
data.avgSalesCycle = { sampleSize: rows.length, medianDays: median, meanDays: mean };
}
}
// ─── Pipeline value breakdown ───────────────────────────────────
// Uses the existing forecast service so the breakdown matches the
// dashboard tile exactly (same per-stage weights, same definition
// of active interests, same dealsMissingPrice surface).
if (want.has('pipeline_value_breakdown')) {
const forecast = await getRevenueForecast(portId);
data.pipelineValueBreakdown = forecast.stageBreakdown
.filter((s) => s.count > 0)
.map((s) => ({
stage: s.stage,
gross: s.grossValue,
weighted: s.weightedValue,
deals: s.count,
// The forecast service doesn't return a port-currency hint;
// default to EUR which matches the seeded berths schema. A
// multi-currency-aware breakdown would need extra plumbing.
currency: 'EUR',
}));
}
// ─── Berth demand ranking ───────────────────────────────────────
if (want.has('berth_demand_ranking')) {
const rows = await db
.select({
mooring: berths.mooringNumber,
c: count(interestBerths.berthId),
})
.from(berths)
.leftJoin(interestBerths, eq(interestBerths.berthId, berths.id))
.leftJoin(
interests,
and(eq(interests.id, interestBerths.interestId), sql`${interests.archivedAt} IS NULL`),
)
.where(eq(berths.portId, portId))
.groupBy(berths.mooringNumber)
.orderBy(desc(count(interestBerths.berthId)))
.limit(10);
data.berthDemandRanking = rows
.filter((r) => Number(r.c) > 0)
.map((r) => ({
mooringNumber: r.mooring,
interestCount: Number(r.c),
// Heat-tier placeholder; the real tier computation lives in
// berth-heat.service.ts and gets stitched in once we plumb it
// through. Keeps the column populated meanwhile.
tier: 'A' as const,
}));
}
// ─── Pending placeholders ───────────────────────────────────────
const pending = widgetIds.filter((id) => PENDING_RESOLVER_IDS.has(id));
if (pending.length > 0) data.stubsPending = pending;
// Silence unused-symbol warnings if documents is included in future
// resolvers — keeps the import where it'll be needed.
void documents;
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
return data;
}