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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user