feat(reports-p4+p5): landing page + per-kind builder + Templates/Runs/Schedules sub-pages

P4 — landing + builder:
- /[portSlug]/reports — new landing page with 4 build-kind cards
  (dashboard / clients / berths / interests), 3 library cards
  (Templates / Runs / Schedules), and the pre-P4 reports list
  preserved under "Legacy library" so historical PDFs stay accessible.
- /[portSlug]/reports/[kind] — kind-aware builder route.
  - dashboard: refactored the existing export dialog body into
    DashboardReportBuilder (page-mounted; same widget grouping +
    date-range + SavedTemplatesPicker + preview). New "Queue + go to
    Runs" CTA enqueues a report_runs row via /api/v1/reports/runs
    (Reports P3 path); "Download PDF" keeps the synchronous /generate
    fallback for ad-hoc one-shots.
  - clients / berths / interests: SimpleReportBuilder — date-range +
    enqueue to /api/v1/reports/runs. Kind-specific filters land
    alongside dedicated renderers in P6+.
- Dashboard "Export as PDF" button rewired: no longer opens an
  in-dashboard Dialog. Becomes a Link → /reports/dashboard?from=...&to=...
  carrying the currently-active range through search params so the
  builder pre-fills it. Removes the dialog body (~290 lines) from the
  button file; the same UI lives in DashboardReportBuilder.
- ?from=YYYY-MM-DD&to=YYYY-MM-DD search params pass the range into the
  builder page.

P5 — sub-pages (functional, backed by P2 CRUD endpoints):
- /reports/runs — paginated table of report_runs with status badges,
  auto-polls every 5s while any row is pending/rendering, per-row
  Download (file by storageKey) + Re-run actions.
- /reports/templates — saved template grid. Clicking the name links to
  the builder with ?templateId=… so it pre-applies.
- /reports/schedules — schedule table with cadence labels (weekly /
  monthly / quarterly), next-run timestamps, recipient counts, and a
  per-row enable Switch (PATCH /api/v1/reports/schedules/[id]).

Verified: tsc clean, 1493/1493 vitest, dev-server compile clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:28:26 +02:00
parent dd25ccfb53
commit 2072f6cac0
11 changed files with 1145 additions and 322 deletions

View File

@@ -0,0 +1,60 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowLeft } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
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];
interface PageProps {
params: Promise<{ portSlug: string; kind: string }>;
searchParams: Promise<{ from?: string; to?: string }>;
}
const KIND_LABELS: Record<Kind, { title: string; description: string }> = {
dashboard: {
title: 'Dashboard report',
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.' },
};
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];
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>
}
/>
{typedKind === 'dashboard' ? (
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
) : (
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
)}
</div>
);
}

View File

@@ -1,5 +1,163 @@
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowRight, BarChart3, Calendar, Clock, FileText, Layers, Users } 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';
export default function ReportsPage() {
return <ReportsPageClient />;
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;
}> = [
{
href: '/reports/templates',
label: 'Templates',
description: 'Saved configurations reps can re-run with one click.',
icon: Layers,
},
{
href: '/reports/runs',
label: 'Runs',
description: 'Every report you have generated, with re-run and re-email links.',
icon: Clock,
},
{
href: '/reports/schedules',
label: 'Schedules',
description: 'Recurring reports that auto-email to your recipient list.',
icon: Calendar,
},
];
export default async function ReportsLandingPage({ params }: PageProps) {
const { portSlug } = await params;
return (
<div className="space-y-6">
<PageHeader
title="Reports"
description="Generate port reports as PDF — on-demand or on a recurring schedule."
/>
<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>
);
})}
</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>
);
})}
</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,10 @@
import { ReportRunsPageClient } from '@/components/reports/sub-pages/report-runs-page-client';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
export default async function ReportRunsPage({ params }: PageProps) {
const { portSlug } = await params;
return <ReportRunsPageClient portSlug={portSlug} />;
}

View File

@@ -0,0 +1,10 @@
import { ReportSchedulesPageClient } from '@/components/reports/sub-pages/report-schedules-page-client';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
export default async function ReportSchedulesPage({ params }: PageProps) {
const { portSlug } = await params;
return <ReportSchedulesPageClient portSlug={portSlug} />;
}

View File

@@ -0,0 +1,10 @@
import { ReportTemplatesPageClient } from '@/components/reports/sub-pages/report-templates-page-client';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
export default async function ReportTemplatesPage({ params }: PageProps) {
const { portSlug } = await params;
return <ReportTemplatesPageClient portSlug={portSlug} />;
}