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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal 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} />;
|
||||
}
|
||||
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal 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} />;
|
||||
}
|
||||
97
src/app/api/v1/reports/custom/run/route.ts
Normal file
97
src/app/api/v1/reports/custom/run/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
|
||||
|
||||
/**
|
||||
* POST /api/v1/reports/custom/run
|
||||
*
|
||||
* Executes a custom-report query and returns raw rows. The UI calls
|
||||
* this with the entity + selected columns + optional date range; the
|
||||
* service resolves the column allowlist and runs the underlying
|
||||
* Drizzle query.
|
||||
*
|
||||
* Permission: `reports.export` — same gate as the saved-template
|
||||
* endpoints (anyone who can export reports can run a custom slice).
|
||||
*
|
||||
* The handler returns JSON `{ data: rows[] }` rather than streaming
|
||||
* CSV — the client serializes via the existing CSV exporter so all
|
||||
* download formats (CSV/XLSX/PDF) reuse one code path.
|
||||
*/
|
||||
const bodySchema = z.object({
|
||||
entity: z.enum(ENTITY_KEYS),
|
||||
columns: z.array(z.string().min(1)).min(1).max(50),
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reports', 'export', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const def = ENTITY_REGISTRY[body.entity as EntityKey];
|
||||
|
||||
// Cross-validate columns against the registry's allowlist.
|
||||
const allowedKeys = new Set(def.columns.map((c) => c.key));
|
||||
const requested = body.columns.filter((k) => allowedKeys.has(k));
|
||||
if (requested.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `No valid columns selected for entity "${body.entity}"` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const filter = {
|
||||
from: body.from ? new Date(body.from) : undefined,
|
||||
to: body.to ? new Date(body.to) : undefined,
|
||||
};
|
||||
|
||||
const rows = await def.runQuery({
|
||||
portId: ctx.portId,
|
||||
columns: requested,
|
||||
filter,
|
||||
});
|
||||
|
||||
// Money columns travel with a hidden sibling currency column so the
|
||||
// client formatter can render `€1,234,567` instead of bare numbers
|
||||
// even when the user didn't tick the currency column for display.
|
||||
// The sibling is stripped from the meta-column list below (so the
|
||||
// table doesn't render it twice) but survives in the row payload.
|
||||
const MONEY_SIBLINGS: Record<string, string> = {
|
||||
price: 'priceCurrency',
|
||||
depositExpectedAmount: 'depositExpectedCurrency',
|
||||
};
|
||||
const siblingsToAttach = new Set<string>();
|
||||
for (const k of requested) {
|
||||
const sib = MONEY_SIBLINGS[k];
|
||||
if (sib && allowedKeys.has(sib)) siblingsToAttach.add(sib);
|
||||
}
|
||||
const projectionKeys = [...requested, ...siblingsToAttach].filter(
|
||||
(k, idx, arr) => arr.indexOf(k) === idx,
|
||||
);
|
||||
|
||||
const projected = rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of projectionKeys) out[k] = row[k];
|
||||
return out;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: projected,
|
||||
meta: {
|
||||
entity: body.entity,
|
||||
columns: requested.map((k) => ({
|
||||
key: k,
|
||||
label: def.columns.find((c) => c.key === k)?.label ?? k,
|
||||
})),
|
||||
rowCount: projected.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
123
src/app/api/v1/reports/export-pdf/route.ts
Normal file
123
src/app/api/v1/reports/export-pdf/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { createElement } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
|
||||
|
||||
/**
|
||||
* POST /api/v1/reports/export-pdf
|
||||
*
|
||||
* Generic PDF generator. Client posts a JSON `ReportPayload`; server
|
||||
* resolves branding for the active port, renders the payload through
|
||||
* the shared PayloadReportDocument, and streams back the PDF bytes.
|
||||
*
|
||||
* Used by every report's export-button dropdown ("Download PDF"
|
||||
* option) so we don't have to keep adding routes per report kind.
|
||||
*/
|
||||
|
||||
// Minimal shape validation — full ReportPayload is structurally typed
|
||||
// in TS; here we just check it has the basic envelope.
|
||||
const payloadSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
filenameSlug: z.string().min(1),
|
||||
range: z.object({
|
||||
from: z.string().datetime(),
|
||||
to: z.string().datetime(),
|
||||
}),
|
||||
kpis: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
sections: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
align: z.enum(['left', 'right', 'center']).optional(),
|
||||
}),
|
||||
),
|
||||
rows: z.array(z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
),
|
||||
/** Optional filename override (without extension) — the client
|
||||
* passes the slug derived from the custom title. */
|
||||
filenameOverride: z.string().optional(),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, payloadSchema);
|
||||
|
||||
// Resolve port branding (logo + primary color + name)
|
||||
const portRow = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, ctx.portId),
|
||||
columns: { name: true },
|
||||
});
|
||||
if (!portRow) throw new NotFoundError('Port');
|
||||
const cfg = await getPortBrandingConfig(ctx.portId);
|
||||
const branding = {
|
||||
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
|
||||
primaryColor: cfg.primaryColor,
|
||||
portName: portRow.name,
|
||||
};
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
// Convert ISO date strings back to Date objects for the payload
|
||||
// (client side serialised them through JSON).
|
||||
const payload = {
|
||||
...body,
|
||||
range: {
|
||||
from: new Date(body.range.from),
|
||||
to: new Date(body.range.to),
|
||||
},
|
||||
// The format-callback isn't transferable through JSON; the
|
||||
// PDF document falls back to formatPlain when undefined,
|
||||
// which is the same default the CSV exporter falls back to.
|
||||
sections: body.sections.map((s) => ({
|
||||
...s,
|
||||
columns: s.columns.map((c) => ({ ...c, format: undefined })),
|
||||
})),
|
||||
};
|
||||
|
||||
const element = createElement(PayloadReportDocument, {
|
||||
payload,
|
||||
branding,
|
||||
generatedAt,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const buffer = await renderToBuffer(element as any);
|
||||
|
||||
const filename =
|
||||
body.filenameOverride ??
|
||||
`${body.filenameSlug}-${body.range.from.slice(0, 10)}_${body.range.to.slice(0, 10)}.pdf`;
|
||||
|
||||
return new NextResponse(buffer as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
98
src/app/api/v1/reports/operational/route.ts
Normal file
98
src/app/api/v1/reports/operational/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
getOperationalKpis,
|
||||
getUtilisationHeatmap,
|
||||
getStatusMixOverTime,
|
||||
getTenancyChurn,
|
||||
getTenureDistribution,
|
||||
getSigningBoxPlot,
|
||||
getOccupancyByArea,
|
||||
getDocumentsInPipeline,
|
||||
getTenanciesEndingSoon,
|
||||
getVacantBerths,
|
||||
getStuckSigning,
|
||||
getHighestValueVacant,
|
||||
} from '@/lib/services/reports/operational.service';
|
||||
|
||||
const querySchema = z.object({
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
|
||||
const now = new Date();
|
||||
const defaultFrom = new Date(now);
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 30);
|
||||
return {
|
||||
from: from ? new Date(from) : defaultFrom,
|
||||
to: to ? new Date(to) : now,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const params = req.nextUrl.searchParams;
|
||||
const { from, to } = querySchema.parse({
|
||||
from: params.get('from') ?? undefined,
|
||||
to: params.get('to') ?? undefined,
|
||||
});
|
||||
const range = resolveRange(from, to);
|
||||
|
||||
const [
|
||||
kpis,
|
||||
utilisationHeatmap,
|
||||
statusMix,
|
||||
tenancyChurn,
|
||||
tenureDistribution,
|
||||
signingBoxPlot,
|
||||
occupancyByArea,
|
||||
docsInPipeline,
|
||||
endingSoon,
|
||||
vacantBerths,
|
||||
stuckSigning,
|
||||
highestValueVacant,
|
||||
] = await Promise.all([
|
||||
getOperationalKpis(ctx.portId, range),
|
||||
getUtilisationHeatmap(ctx.portId),
|
||||
getStatusMixOverTime(ctx.portId),
|
||||
getTenancyChurn(ctx.portId),
|
||||
getTenureDistribution(ctx.portId),
|
||||
getSigningBoxPlot(ctx.portId),
|
||||
getOccupancyByArea(ctx.portId),
|
||||
getDocumentsInPipeline(ctx.portId),
|
||||
getTenanciesEndingSoon(ctx.portId),
|
||||
getVacantBerths(ctx.portId),
|
||||
getStuckSigning(ctx.portId),
|
||||
getHighestValueVacant(ctx.portId),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
kpis,
|
||||
utilisationHeatmap,
|
||||
statusMix,
|
||||
tenancyChurn,
|
||||
tenureDistribution,
|
||||
signingBoxPlot,
|
||||
occupancyByArea,
|
||||
docsInPipeline,
|
||||
endingSoon,
|
||||
vacantBerths,
|
||||
stuckSigning,
|
||||
highestValueVacant,
|
||||
range: {
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
161
src/app/api/v1/reports/sales/route.ts
Normal file
161
src/app/api/v1/reports/sales/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import {
|
||||
getSalesKpis,
|
||||
getPipelineFunnel,
|
||||
getStageVelocity,
|
||||
getWinRateOverTime,
|
||||
getSourceConversion,
|
||||
getRepLeaderboard,
|
||||
getDealHeat,
|
||||
getRepPerformanceDetail,
|
||||
getStalledDeals,
|
||||
getClosingThisMonth,
|
||||
getRecentWins,
|
||||
getLostReasonBreakdown,
|
||||
type SalesFilters,
|
||||
} from '@/lib/services/reports/sales.service';
|
||||
|
||||
const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
||||
const OUTCOMES = [
|
||||
'won',
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* GET /api/v1/reports/sales?from=&to=
|
||||
*
|
||||
* Returns the Sales Performance report payload for the active port:
|
||||
* the 7 KPI tiles + the pipeline funnel (chart 1). Further charts +
|
||||
* tables ship on this same endpoint as they're built; the response
|
||||
* shape grows additively under a single `data` envelope.
|
||||
*
|
||||
* Permission: `reports.view_dashboard` (same gate as the existing
|
||||
* dashboard report endpoints; the Sales report is the canonical "for
|
||||
* leadership" surface).
|
||||
*/
|
||||
|
||||
const querySchema = z.object({
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
// CSV-style list params. Empty string → undefined → no filter.
|
||||
stage: z.string().optional(),
|
||||
leadCategory: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse a CSV filter param into a typed allowlist. Unknown values are
|
||||
* silently dropped — that way a stale bookmark with a removed enum
|
||||
* value degrades to "no filter" instead of 400.
|
||||
*/
|
||||
function parseCsv<T extends string>(
|
||||
raw: string | undefined,
|
||||
allowed: ReadonlyArray<T>,
|
||||
): T[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is T => (allowed as ReadonlyArray<string>).includes(s));
|
||||
return parts.length > 0 ? parts : undefined;
|
||||
}
|
||||
|
||||
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
|
||||
const now = new Date();
|
||||
// Defaults: trailing 30 days. Matches the "Last 30 days" preset on
|
||||
// the date-range picker so a no-argument GET returns the same thing
|
||||
// the default UI state shows.
|
||||
const defaultFrom = new Date(now);
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 30);
|
||||
return {
|
||||
from: from ? new Date(from) : defaultFrom,
|
||||
to: to ? new Date(to) : now,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
try {
|
||||
const params = req.nextUrl.searchParams;
|
||||
const { from, to, stage, leadCategory, outcome } = querySchema.parse({
|
||||
from: params.get('from') ?? undefined,
|
||||
to: params.get('to') ?? undefined,
|
||||
stage: params.get('stage') ?? undefined,
|
||||
leadCategory: params.get('leadCategory') ?? undefined,
|
||||
outcome: params.get('outcome') ?? undefined,
|
||||
});
|
||||
const range = resolveRange(from, to);
|
||||
|
||||
const filters: SalesFilters | undefined = (() => {
|
||||
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
|
||||
const leadCategories = parseCsv<(typeof LEAD_CATEGORIES)[number]>(
|
||||
leadCategory,
|
||||
LEAD_CATEGORIES,
|
||||
);
|
||||
const outcomes = parseCsv<(typeof OUTCOMES)[number]>(outcome, OUTCOMES);
|
||||
if (!stages && !leadCategories && !outcomes) return undefined;
|
||||
return { stages, leadCategories, outcomes };
|
||||
})();
|
||||
|
||||
const [
|
||||
kpis,
|
||||
funnel,
|
||||
stageVelocity,
|
||||
winRateOverTime,
|
||||
sourceConversion,
|
||||
repLeaderboard,
|
||||
dealHeat,
|
||||
repPerformanceDetail,
|
||||
stalledDeals,
|
||||
closingThisMonth,
|
||||
recentWins,
|
||||
lostReasonBreakdown,
|
||||
] = await Promise.all([
|
||||
getSalesKpis(ctx.portId, range),
|
||||
getPipelineFunnel(ctx.portId),
|
||||
getStageVelocity(ctx.portId),
|
||||
getWinRateOverTime(ctx.portId, range),
|
||||
getSourceConversion(ctx.portId),
|
||||
getRepLeaderboard(ctx.portId, range),
|
||||
getDealHeat(ctx.portId),
|
||||
getRepPerformanceDetail(ctx.portId, range, filters),
|
||||
getStalledDeals(ctx.portId, filters),
|
||||
getClosingThisMonth(ctx.portId, filters),
|
||||
getRecentWins(ctx.portId, filters),
|
||||
getLostReasonBreakdown(ctx.portId, range, filters),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
kpis,
|
||||
funnel,
|
||||
stageVelocity,
|
||||
winRateOverTime,
|
||||
sourceConversion,
|
||||
repLeaderboard,
|
||||
dealHeat,
|
||||
repPerformanceDetail,
|
||||
stalledDeals,
|
||||
closingThisMonth,
|
||||
recentWins,
|
||||
lostReasonBreakdown,
|
||||
range: {
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -7,7 +7,12 @@ import { errorResponse } from '@/lib/errors';
|
||||
import { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
|
||||
|
||||
const createBodySchema = z.object({
|
||||
kind: z.enum(['dashboard', 'clients', 'berths', 'interests']),
|
||||
// 'sales' + 'operational' don't go through /api/v1/reports/generate;
|
||||
// they're standalone report pages with their own routes. The config
|
||||
// for these kinds is a thin view-state snapshot (date range +
|
||||
// filters) that the report client applies on load. 'custom' is the
|
||||
// ad-hoc composer's saved config — entity + columns + filter.
|
||||
kind: z.enum(['dashboard', 'clients', 'berths', 'interests', 'sales', 'operational', 'custom']),
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(400).nullable().optional(),
|
||||
// Config is the raw discriminated-union payload; the
|
||||
|
||||
Reference in New Issue
Block a user