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} />;
}

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

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

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

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

View File

@@ -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