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:
60
src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx
Normal file
60
src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
10
src/app/(dashboard)/[portSlug]/reports/runs/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/runs/page.tsx
Normal 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} />;
|
||||
}
|
||||
10
src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx
Normal 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} />;
|
||||
}
|
||||
10
src/app/(dashboard)/[portSlug]/reports/templates/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/reports/templates/page.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user