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

@@ -1,60 +1,155 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowLeft } from 'lucide-react';
import { ArrowRight, ChevronLeft, Wrench } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DashboardReportBuilder } from '@/components/reports/builders/dashboard-report-builder';
import { SimpleReportBuilder } from '@/components/reports/builders/simple-report-builder';
const KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
type Kind = (typeof KINDS)[number];
/**
* Two generations of report kinds live here:
*
* - LEGACY_KINDS: the original 2026-Q1 builders (dashboard, clients,
* berths, interests). Functional today via the existing
* SimpleReportBuilder / DashboardReportBuilder.
* - NEW_KINDS: the four canonical categories from the 2026-05-27 launch
* initiative (sales, financial, marketing, operational), plus the
* custom ad-hoc composer. Each currently renders a placeholder so
* the new landing page routes here without 404-ing; the actual
* builders ship per the launch-readiness doc.
*/
const LEGACY_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
const NEW_KINDS = ['sales', 'financial', 'marketing', 'operational', 'custom'] as const;
type LegacyKind = (typeof LEGACY_KINDS)[number];
type NewKind = (typeof NEW_KINDS)[number];
interface PageProps {
params: Promise<{ portSlug: string; kind: string }>;
searchParams: Promise<{ from?: string; to?: string }>;
}
const KIND_LABELS: Record<Kind, { title: string; description: string }> = {
const LEGACY_LABELS: Record<LegacyKind, { title: string; description: string }> = {
dashboard: {
title: 'Dashboard report',
description: 'Multi-section PDF of the port dashboard pick which sections to include.',
description: 'Multi-section PDF of the port dashboard - pick which sections to include.',
},
clients: { title: 'Clients report', description: 'Activity snapshot for active clients.' },
berths: { title: 'Berths report', description: 'Occupancy + status mix per berth.' },
interests: { title: 'Interests report', description: 'Pipeline value + stage distribution.' },
};
const NEW_LABELS: Record<NewKind, { title: string; description: string }> = {
sales: {
title: 'Sales performance',
description: 'Rep leaderboards, win rates, time-to-close, stalled deals, conversion funnel.',
},
financial: {
title: 'Financial',
description: 'Revenue by month, deposits collected, AR aging, EOI to revenue conversion.',
},
marketing: {
title: 'Marketing & funnel',
description: 'Lead source ROI, inquiry-to-EOI conversion, attribution by campaign.',
},
operational: {
title: 'Operational',
description: 'Berth utilisation, occupancy heatmap, tenancy churn, signing turnaround.',
},
custom: {
title: 'Custom report',
description:
'Compose your own. Pick an entity, choose columns and filters, group by any dimension.',
},
};
export default async function ReportBuilderPage({ params, searchParams }: PageProps) {
const { portSlug, kind } = await params;
const { from, to } = await searchParams;
if (!(KINDS as readonly string[]).includes(kind)) notFound();
const typedKind = kind as Kind;
const labels = KIND_LABELS[typedKind];
const isLegacy = (LEGACY_KINDS as readonly string[]).includes(kind);
const isNew = (NEW_KINDS as readonly string[]).includes(kind);
if (!isLegacy && !isNew) notFound();
if (isLegacy) {
const typedKind = kind as LegacyKind;
const labels = LEGACY_LABELS[typedKind];
return (
<div className="space-y-4">
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
{typedKind === 'dashboard' ? (
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
) : (
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
)}
</div>
);
}
// New-kind placeholder. Uses the standard PageHeader + Card pattern so
// it reads as part of the same app while the actual builders ship.
const typedKind = kind as NewKind;
const labels = NEW_LABELS[typedKind];
return (
<div className="space-y-4">
<PageHeader
eyebrow="Reports"
title={labels.title}
description={labels.description}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden />
All reports
</Link>
</Button>
}
/>
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
{typedKind === 'dashboard' ? (
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
) : (
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
)}
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Wrench className="h-5 w-5 mt-0.5 text-muted-foreground" aria-hidden />
<div className="flex-1">
<CardTitle className="text-base">Builder in development</CardTitle>
<CardDescription className="mt-1">
The {labels.title.toLowerCase()} builder is shipping as part of the active launch
initiative. In the meantime the legacy builders below cover most of the same data.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ChevronLeft className="mr-1.5 h-4 w-4" aria-hidden />
Back to reports
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/${portSlug}/reports/dashboard` as Route}>
Open dashboard report
<ArrowRight className="ml-1.5 h-4 w-4" aria-hidden />
</Link>
</Button>
</div>
</CardContent>
</Card>
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Legacy builders
</h2>
<p className="text-xs text-muted-foreground/80">
Available now while the new category builders are filled in.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{(LEGACY_KINDS as readonly LegacyKind[]).map((k) => (
<Link key={k} href={`/${portSlug}/reports/${k}` as Route} className="block group">
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="pb-2">
<CardTitle className="text-base">{LEGACY_LABELS[k].title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{LEGACY_LABELS[k].description}</CardDescription>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { CustomReportBuilder } from '@/components/reports/custom/custom-report-builder';
export const dynamic = 'force-dynamic';
/**
* Custom (ad-hoc) report builder. Sibling of the dynamic [kind] route
* so this page wins over the placeholder for /reports/custom.
*
* v1 ships 4 entities: clients / interests / berths / tenancies.
* Additional entities (companies, yachts, invoices, payments, deals,
* sends) layer in via `src/lib/reports/custom/registry.ts` without
* touching this page.
*/
export default async function CustomReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <CustomReportBuilder portSlug={portSlug} />;
}

View File

@@ -0,0 +1,19 @@
import { OperationalReportClient } from '@/components/reports/operational/operational-report-client';
export const dynamic = 'force-dynamic';
/**
* Operational report.
*
* Sibling of the dynamic [kind] route so this page wins for
* /reports/operational specifically. Spec lives in
* docs/reports-content-spec.md § Report 04.
*/
export default async function OperationalReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <OperationalReportClient portSlug={portSlug} />;
}

View File

@@ -1,76 +1,147 @@
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowRight, BarChart3, Calendar, Clock, FileText, Layers, Users } from 'lucide-react';
import {
BookOpen,
Calendar,
Clock,
DollarSign,
Layers,
Megaphone,
Sparkles,
TrendingUp,
} from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ReportsPageClient } from '@/components/reports/reports-page-client';
import { cn } from '@/lib/utils';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
interface KindCard {
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
title: string;
description: string;
icon: typeof BarChart3;
}
const KINDS: KindCard[] = [
{
kind: 'dashboard',
title: 'Dashboard report',
description:
'Multi-section PDF of the port dashboard — pipeline funnel, occupancy timeline, KPIs, lead sources.',
icon: BarChart3,
},
{
kind: 'clients',
title: 'Clients report',
description: 'Activity snapshot across every active client in a date window.',
icon: Users,
},
{
kind: 'berths',
title: 'Berths report',
description: 'Occupancy + status mix for every berth across the requested window.',
icon: Layers,
},
{
kind: 'interests',
title: 'Interests report',
description: 'Pipeline value + stage distribution for every interest.',
icon: FileText,
},
];
const SUB_PAGES: Array<{
href: string;
label: string;
description: string;
icon: typeof BarChart3;
}> = [
icon: typeof TrendingUp;
}
/**
* Five entry points - four canonical categories from the launch
* initiative (sales / financial / marketing / operational) plus the
* ad-hoc custom composer. Rendered with the same CardHeader +
* CardDescription pattern as the admin sections browser so this surface
* reads as part of the same app.
*/
const KIND_CARDS: KindCard[] = [
{
href: 'sales',
label: 'Sales performance',
description:
'Rep leaderboards, win rates, average time-to-close, stalled deals, conversion funnel by stage.',
icon: TrendingUp,
},
{
href: 'financial',
label: 'Financial',
description: 'Revenue by month, deposits collected, AR aging, EOI to revenue conversion.',
icon: DollarSign,
},
{
href: 'marketing',
label: 'Marketing & funnel',
description:
'Lead source ROI, inquiry-to-EOI conversion, attribution by campaign, lead reports.',
icon: Megaphone,
},
{
href: 'operational',
label: 'Operational',
description:
'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.',
icon: Layers,
},
{
href: 'custom',
label: 'Custom report',
description:
'Build your own: pick an entity, choose columns and filters, group by any dimension, save as a template.',
icon: Sparkles,
},
];
interface LibraryCard {
href: string;
label: string;
description: string;
icon: typeof Calendar;
}
const LIBRARY_CARDS: LibraryCard[] = [
{
href: '/reports/templates',
label: 'Templates',
description: 'Saved configurations reps can re-run with one click.',
icon: Layers,
description: 'Saved configurations. Modify, re-run, or save as a new template.',
icon: BookOpen,
},
{
href: '/reports/runs',
label: 'Runs',
description: 'Every report you have generated, with re-run and re-email links.',
description: 'Every report generated, with re-run and re-send.',
icon: Clock,
},
{
href: '/reports/schedules',
label: 'Schedules',
description: 'Recurring reports that auto-email to your recipient list.',
description: 'Recurring runs. Email delivery is optional per schedule.',
icon: Calendar,
},
];
interface SectionCardProps {
href: string;
label: string;
description: string;
icon: typeof TrendingUp;
/** Optional small uppercase label rendered above the title, mirroring
* the admin-sections-browser pattern. */
eyebrow?: string;
}
/**
* Matches the SectionCard pattern used on the Administration landing
* page so cards across the app share one visual + interactive idiom.
* Don't restyle this independently - if the admin card style changes,
* propagate here.
*/
function ReportSectionCard({ href, label, description, icon: Icon, eyebrow }: SectionCardProps) {
return (
<Link href={href as Route} className="block group">
<Card
className={cn(
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
)}
>
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
aria-hidden
/>
<div className="flex-1">
<CardTitle className="text-base">{label}</CardTitle>
{eyebrow ? (
<p className="text-xs uppercase tracking-wider text-muted-foreground">{eyebrow}</p>
) : null}
</div>
</CardHeader>
<CardContent>
<CardDescription>{description}</CardDescription>
</CardContent>
</Card>
</Link>
);
}
export default async function ReportsLandingPage({ params }: PageProps) {
const { portSlug } = await params;
@@ -78,85 +149,51 @@ export default async function ReportsLandingPage({ params }: PageProps) {
<div className="space-y-6">
<PageHeader
title="Reports"
description="Generate port reports as PDF — on-demand or on a recurring schedule."
description="Generate curated and ad-hoc reports as PDF, CSV, or Excel. Schedule recurring runs with optional email delivery."
/>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Build a new report
</h2>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{KINDS.map((k) => {
const Icon = k.icon;
return (
<Link
key={k.kind}
href={`/${portSlug}/reports/${k.kind}` as Route}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden />
</div>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{k.title}</h3>
<ArrowRight
className="h-3.5 w-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{k.description}</p>
</Link>
);
})}
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Compose a report
</h2>
<p className="text-xs text-muted-foreground/80">
Four canonical categories plus an ad-hoc composer for anything else.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{KIND_CARDS.map((k) => (
<ReportSectionCard
key={k.href}
href={`/${portSlug}/reports/${k.href}`}
label={k.label}
description={k.description}
icon={k.icon}
/>
))}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Library
</h2>
<div className="grid gap-3 md:grid-cols-3">
{SUB_PAGES.map((s) => {
const Icon = s.icon;
return (
<Link
key={s.href}
href={`/${portSlug}${s.href}` as Route}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="h-4 w-4" aria-hidden />
</div>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{s.label}</h3>
<ArrowRight
className="h-3.5 w-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{s.description}</p>
</Link>
);
})}
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Library
</h2>
<p className="text-xs text-muted-foreground/80">
Saved templates, generated runs, and recurring schedules. Re-run anything in one click.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{LIBRARY_CARDS.map((l) => (
<ReportSectionCard
key={l.href}
href={`/${portSlug}${l.href}`}
label={l.label}
description={l.description}
icon={l.icon}
/>
))}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Legacy library
</h2>
<Card>
<CardHeader>
<CardTitle className="text-sm">Older reports + ad-hoc generator</CardTitle>
<CardDescription>
Pre-P4 reports surface. Stays available so historical PDFs are still downloadable
while the new template / run / schedule surfaces fill in.
</CardDescription>
</CardHeader>
<CardContent>
<ReportsPageClient />
</CardContent>
</Card>
</section>
</div>
);

View File

@@ -0,0 +1,19 @@
import { SalesReportClient } from '@/components/reports/sales/sales-report-client';
export const dynamic = 'force-dynamic';
/**
* Sales Performance report.
*
* Sibling of the dynamic [kind] route so the page wins over the
* placeholder for /reports/sales specifically. Spec lives in
* docs/reports-content-spec.md § Report 01.
*/
export default async function SalesReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <SalesReportClient portSlug={portSlug} />;
}