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';
|
import { ReportsPageClient } from '@/components/reports/reports-page-client';
|
||||||
|
|
||||||
export default function ReportsPage() {
|
interface PageProps {
|
||||||
return <ReportsPageClient />;
|
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} />;
|
||||||
|
}
|
||||||
347
src/components/reports/builders/dashboard-report-builder.tsx
Normal file
347
src/components/reports/builders/dashboard-report-builder.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
PDF_DASHBOARD_WIDGETS,
|
||||||
|
PDF_DASHBOARD_CATEGORY_LABELS,
|
||||||
|
type PdfDashboardWidgetId,
|
||||||
|
type PdfDashboardWidgetCategory,
|
||||||
|
} from '@/lib/services/dashboard-report-widgets';
|
||||||
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
|
import {
|
||||||
|
SavedTemplatesPicker,
|
||||||
|
type SavedTemplate,
|
||||||
|
} from '@/components/reports/saved-templates-picker';
|
||||||
|
import { PdfPreviewModal } from '@/components/reports/pdf-preview-modal';
|
||||||
|
|
||||||
|
function toIsoLocal(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portSlug: string;
|
||||||
|
/** YYYY-MM-DD from a URL search-param so deep-links from the dashboard
|
||||||
|
* Export button can pre-fill the range the rep was already viewing. */
|
||||||
|
initialFrom?: string;
|
||||||
|
initialTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-mounted Dashboard report builder. Migrates the export dialog body
|
||||||
|
* into a dedicated page (Reports P4). Same widget grouping + date-range
|
||||||
|
* controls + preview + saved-templates picker; the only behaviour change
|
||||||
|
* is that submit no longer closes a Dialog — it stays on the builder so
|
||||||
|
* the rep can tweak + re-export.
|
||||||
|
*/
|
||||||
|
export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString(undefined)}`);
|
||||||
|
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
||||||
|
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||||
|
);
|
||||||
|
const today = new Date();
|
||||||
|
const last30 = new Date(today);
|
||||||
|
last30.setDate(last30.getDate() - 30);
|
||||||
|
const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30));
|
||||||
|
const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [enqueuing, setEnqueuing] = useState(false);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
const previewPayload = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: title.trim() || 'Report',
|
||||||
|
config: {
|
||||||
|
kind: 'dashboard' as const,
|
||||||
|
widgetIds: selected,
|
||||||
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
|
...(dateTo ? { dateTo } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[title, selected, dateFrom, dateTo],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(id: PdfDashboardWidgetId) {
|
||||||
|
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portHeader(): Promise<HeadersInit> {
|
||||||
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||||
|
const portId = await resolvePortIdFromSlug(portSlug);
|
||||||
|
if (portId) headers.set('X-Port-Id', portId);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
toast.error('Pick at least one section to include.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/reports/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await portHeader(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim() || 'Report',
|
||||||
|
config: {
|
||||||
|
kind: 'dashboard',
|
||||||
|
widgetIds: selected,
|
||||||
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
|
...(dateTo ? { dateTo } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Export failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
|
||||||
|
triggerBlobDownload(blob, filename);
|
||||||
|
toast.success('Report downloaded');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Export failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P3 path: enqueue a `report_runs` row instead of doing a synchronous
|
||||||
|
* download. The BullMQ worker picks it up, renders, and parks the file
|
||||||
|
* on the report row. The rep then sees it in the /reports/runs list.
|
||||||
|
*/
|
||||||
|
async function handleEnqueueRun() {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
toast.error('Pick at least one section to include.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnqueuing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/reports/runs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await portHeader(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: 'dashboard',
|
||||||
|
config: {
|
||||||
|
kind: 'dashboard',
|
||||||
|
widgetIds: selected,
|
||||||
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
|
...(dateTo ? { dateTo } : {}),
|
||||||
|
},
|
||||||
|
outputFormat: 'pdf',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Enqueue failed (${res.status})`);
|
||||||
|
}
|
||||||
|
toast.success('Report queued — track progress in Runs.');
|
||||||
|
router.push(`/${portSlug}/reports/runs`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Enqueue failed');
|
||||||
|
} finally {
|
||||||
|
setEnqueuing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SavedTemplatesPicker
|
||||||
|
kind="dashboard"
|
||||||
|
currentConfig={{ widgetIds: selected }}
|
||||||
|
onApply={(t: SavedTemplate) => {
|
||||||
|
const cfg = t.config as { widgetIds?: string[] };
|
||||||
|
if (Array.isArray(cfg.widgetIds)) {
|
||||||
|
setSelected(
|
||||||
|
cfg.widgetIds.filter((id): id is PdfDashboardWidgetId =>
|
||||||
|
PDF_DASHBOARD_WIDGETS.some((w) => w.id === id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (t.name) setTitle(t.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Title + window</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="export-title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="export-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Report window</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<DatePicker
|
||||||
|
id="export-date-from"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={setDateFrom}
|
||||||
|
placeholder="Start"
|
||||||
|
size="sm"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<DatePicker
|
||||||
|
id="export-date-to"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={setDateTo}
|
||||||
|
placeholder="End"
|
||||||
|
size="sm"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const t = new Date();
|
||||||
|
const start = new Date(t);
|
||||||
|
start.setDate(start.getDate() - 30);
|
||||||
|
setDateFrom(toIsoLocal(start));
|
||||||
|
setDateTo(toIsoLocal(t));
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
Last 30 days
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const t = new Date();
|
||||||
|
const start = new Date(t);
|
||||||
|
start.setDate(start.getDate() - 90);
|
||||||
|
setDateFrom(toIsoLocal(start));
|
||||||
|
setDateTo(toIsoLocal(t));
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
Last 90 days
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Drives time-period sections (new clients, berths sold, occupancy timeline, etc.).
|
||||||
|
Sections marked “needs date range” only render when both dates are set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Sections</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="max-h-[60vh] space-y-3 overflow-y-auto rounded-md border p-2">
|
||||||
|
{(
|
||||||
|
Object.entries(PDF_DASHBOARD_CATEGORY_LABELS) as Array<
|
||||||
|
[PdfDashboardWidgetCategory, string]
|
||||||
|
>
|
||||||
|
).map(([category, label]) => {
|
||||||
|
const items = PDF_DASHBOARD_WIDGETS.filter((w) => w.category === category);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category} className="space-y-1">
|
||||||
|
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{items.map((w) => (
|
||||||
|
<label
|
||||||
|
key={w.id}
|
||||||
|
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(w.id)}
|
||||||
|
onCheckedChange={() => toggle(w.id)}
|
||||||
|
aria-label={w.label}
|
||||||
|
/>
|
||||||
|
<div className="text-sm leading-tight">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium">{w.label}</span>
|
||||||
|
{w.isChart ? (
|
||||||
|
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-primary">
|
||||||
|
chart
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{w.requiresPeriod ? (
|
||||||
|
<span className="shrink-0 whitespace-nowrap rounded-full bg-amber-100 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-amber-800">
|
||||||
|
needs date range
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{w.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
disabled={loading || enqueuing || selected.length === 0}
|
||||||
|
>
|
||||||
|
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleEnqueueRun}
|
||||||
|
disabled={loading || enqueuing || selected.length === 0}
|
||||||
|
>
|
||||||
|
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
Queue + go to Runs
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleDownload} disabled={loading || enqueuing || selected.length === 0}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewOpen ? (
|
||||||
|
<PdfPreviewModal
|
||||||
|
open
|
||||||
|
onOpenChange={setPreviewOpen}
|
||||||
|
payload={previewPayload}
|
||||||
|
filename={`${title.trim().replace(/[\\/]/g, '_') || 'report'}.pdf`}
|
||||||
|
title={`Preview: ${title.trim() || 'Report'}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/reports/builders/simple-report-builder.tsx
Normal file
118
src/components/reports/builders/simple-report-builder.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
function toIsoLocal(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, { title: string; description: string }> = {
|
||||||
|
clients: {
|
||||||
|
title: 'Clients report',
|
||||||
|
description: 'Activity snapshot for every active client in the window.',
|
||||||
|
},
|
||||||
|
berths: {
|
||||||
|
title: 'Berths report',
|
||||||
|
description: 'Occupancy + status mix for every berth in the window.',
|
||||||
|
},
|
||||||
|
interests: {
|
||||||
|
title: 'Interests report',
|
||||||
|
description: 'Pipeline value + stage distribution for every interest.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portSlug: string;
|
||||||
|
kind: 'clients' | 'berths' | 'interests';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 builder for the non-dashboard kinds. Just a date-range picker that
|
||||||
|
* enqueues a `report_runs` row via /api/v1/reports/runs. Kind-specific
|
||||||
|
* filters land alongside the dedicated renderer in P6+.
|
||||||
|
*/
|
||||||
|
export function SimpleReportBuilder({ portSlug, kind }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const labels = KIND_LABELS[kind] ?? {
|
||||||
|
title: `${kind} report`,
|
||||||
|
description: 'Generate a port-scoped report.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const last30 = new Date(today);
|
||||||
|
last30.setDate(last30.getDate() - 30);
|
||||||
|
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
|
||||||
|
const [dateTo, setDateTo] = useState(toIsoLocal(today));
|
||||||
|
const [enqueuing, setEnqueuing] = useState(false);
|
||||||
|
|
||||||
|
async function handleEnqueue() {
|
||||||
|
setEnqueuing(true);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/reports/runs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
kind,
|
||||||
|
config: { kind, dateFrom, dateTo },
|
||||||
|
outputFormat: 'pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success('Report queued — track progress in Runs.');
|
||||||
|
router.push(`/${portSlug}/reports/runs`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Enqueue failed');
|
||||||
|
} finally {
|
||||||
|
setEnqueuing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">{labels.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{labels.description}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Report window</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<DatePicker
|
||||||
|
id="builder-date-from"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={setDateFrom}
|
||||||
|
placeholder="Start"
|
||||||
|
size="sm"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<DatePicker
|
||||||
|
id="builder-date-to"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={setDateTo}
|
||||||
|
placeholder="End"
|
||||||
|
size="sm"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleEnqueue} disabled={enqueuing}>
|
||||||
|
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
Queue + go to Runs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useParams } from 'next/navigation';
|
||||||
import { Eye, FileDown, Loader2 } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { toast } from 'sonner';
|
import type { Route } from 'next';
|
||||||
|
import { FileDown } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { DatePicker } from '@/components/ui/date-picker';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
PDF_DASHBOARD_WIDGETS,
|
|
||||||
PDF_DASHBOARD_CATEGORY_LABELS,
|
|
||||||
type PdfDashboardWidgetId,
|
|
||||||
type PdfDashboardWidgetCategory,
|
|
||||||
} from '@/lib/services/dashboard-report-widgets';
|
|
||||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
|
||||||
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
||||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
|
||||||
import { PdfPreviewModal } from './pdf-preview-modal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local-timezone YYYY-MM-DD formatter. We deliberately avoid
|
|
||||||
* `toISOString().slice(0,10)` because it rolls through UTC and would
|
|
||||||
* land on the previous day for any rep east of GMT after ~14:00 local.
|
|
||||||
*/
|
|
||||||
function toIsoLocal(d: Date): string {
|
function toIsoLocal(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -44,10 +18,11 @@ function toIsoLocal(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
* Dashboard "Export as PDF" affordance. As of Reports P4 this navigates
|
||||||
* pick which sections to include + set a custom title. Saved-template
|
* to the dedicated `/reports/dashboard` builder page (carrying the
|
||||||
* support lands in Phase C; for now, the dialog defaults all widgets
|
* currently-active range through `?from=YYYY-MM-DD&to=YYYY-MM-DD`)
|
||||||
* checked + the current date in the title.
|
* instead of opening an in-dashboard dialog. The dialog body now lives
|
||||||
|
* in `DashboardReportBuilder`.
|
||||||
*
|
*
|
||||||
* Permission-gated client-side on `reports.export`; the server route
|
* Permission-gated client-side on `reports.export`; the server route
|
||||||
* re-checks via withPermission so a tampered client can't bypass.
|
* re-checks via withPermission so a tampered client can't bypass.
|
||||||
@@ -57,300 +32,36 @@ export function ExportDashboardPdfButton({
|
|||||||
initialRange,
|
initialRange,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** The dashboard's currently-active range. When supplied, drives the
|
/** Carried through to the builder so the rep doesn't re-pick a range
|
||||||
* dialog's initial dateFrom / dateTo so the rep doesn't re-pick a
|
* they just chose on the dashboard. */
|
||||||
* range they just chose on the dashboard. Falls back to last 30 days
|
|
||||||
* when omitted (still useful for ad-hoc reports). */
|
|
||||||
initialRange?: DateRange;
|
initialRange?: DateRange;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const [open, setOpen] = useState(false);
|
const params = useParams<{ portSlug: string }>();
|
||||||
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString(undefined)}`);
|
const portSlug = params?.portSlug ?? '';
|
||||||
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
|
||||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
|
||||||
);
|
|
||||||
// Default report window: honour the dashboard's active range when one
|
|
||||||
// was passed in (rep already chose a window upstream); otherwise default
|
|
||||||
// to last 30 days. Period-cohort + occupancy-timeline widgets require
|
|
||||||
// the window, so populating with sensible defaults means the rep gets a
|
|
||||||
// useful report on first export without re-picking dates.
|
|
||||||
const initialBounds = (() => {
|
|
||||||
if (initialRange) {
|
|
||||||
const { from, to } = rangeToBounds(initialRange);
|
|
||||||
return { from: toIsoLocal(from), to: toIsoLocal(to) };
|
|
||||||
}
|
|
||||||
const today = new Date();
|
|
||||||
const last30 = new Date(today);
|
|
||||||
last30.setDate(last30.getDate() - 30);
|
|
||||||
return { from: toIsoLocal(last30), to: toIsoLocal(today) };
|
|
||||||
})();
|
|
||||||
const [dateFrom, setDateFrom] = useState(initialBounds.from);
|
|
||||||
const [dateTo, setDateTo] = useState(initialBounds.to);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
|
||||||
|
|
||||||
// Build the payload the modal will POST. useMemo keeps the
|
|
||||||
// reference stable while the dialog's form is unchanged, so the
|
|
||||||
// preview effect doesn't re-fire on unrelated re-renders.
|
|
||||||
const previewPayload = useMemo(
|
|
||||||
() => ({
|
|
||||||
title: title.trim() || 'Report',
|
|
||||||
config: {
|
|
||||||
kind: 'dashboard' as const,
|
|
||||||
widgetIds: selected,
|
|
||||||
...(dateFrom ? { dateFrom } : {}),
|
|
||||||
...(dateTo ? { dateTo } : {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[title, selected, dateFrom, dateTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!can('reports', 'export')) return null;
|
if (!can('reports', 'export')) return null;
|
||||||
|
if (!portSlug) return null;
|
||||||
|
|
||||||
function toggle(id: PdfDashboardWidgetId) {
|
const search = (() => {
|
||||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
if (!initialRange) return '';
|
||||||
}
|
const { from, to } = rangeToBounds(initialRange);
|
||||||
|
return `?from=${toIsoLocal(from)}&to=${toIsoLocal(to)}`;
|
||||||
async function handleExport() {
|
})();
|
||||||
if (selected.length === 0) {
|
const href = `/${portSlug}/reports/dashboard${search}` as Route;
|
||||||
toast.error('Pick at least one section to include.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// FormData isn't required (this is a JSON body), but we DO need
|
|
||||||
// to forward the X-Port-Id header so the server-side resolver
|
|
||||||
// knows which port's data to use. apiFetch is JSON-only and
|
|
||||||
// doesn't expose the raw response body; we need the buffer here
|
|
||||||
// so do a raw fetch with the same header convention.
|
|
||||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
|
||||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
|
||||||
const portId = await resolvePortIdFromSlug(slug);
|
|
||||||
if (portId) headers.set('X-Port-Id', portId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await fetch('/api/v1/reports/generate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.trim() || 'Report',
|
|
||||||
config: {
|
|
||||||
kind: 'dashboard',
|
|
||||||
widgetIds: selected,
|
|
||||||
...(dateFrom ? { dateFrom } : {}),
|
|
||||||
...(dateTo ? { dateTo } : {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || `Export failed (${res.status})`);
|
|
||||||
}
|
|
||||||
const blob = await res.blob();
|
|
||||||
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
|
|
||||||
triggerBlobDownload(blob, filename);
|
|
||||||
toast.success('Report downloaded');
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Export failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Button
|
||||||
<Button
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setOpen(true)}
|
title="Export dashboard as PDF"
|
||||||
title="Export dashboard as PDF"
|
aria-label="Export dashboard as PDF"
|
||||||
aria-label="Export dashboard as PDF"
|
className={cn('h-9 w-9 p-0 text-muted-foreground hover:text-foreground', className)}
|
||||||
className={cn('h-9 w-9 p-0 text-muted-foreground hover:text-foreground', className)}
|
>
|
||||||
>
|
<Link href={href}>
|
||||||
<FileDown className="h-4 w-4" aria-hidden />
|
<FileDown className="h-4 w-4" aria-hidden />
|
||||||
</Button>
|
</Link>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
</Button>
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Export dashboard as PDF</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Pick which sections to include and set a title. The PDF inherits the active
|
|
||||||
port's logo and primary color.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SavedTemplatesPicker
|
|
||||||
kind="dashboard"
|
|
||||||
currentConfig={{ widgetIds: selected }}
|
|
||||||
onApply={(t: SavedTemplate) => {
|
|
||||||
const cfg = t.config as { widgetIds?: string[] };
|
|
||||||
if (Array.isArray(cfg.widgetIds)) {
|
|
||||||
setSelected(
|
|
||||||
cfg.widgetIds.filter((id): id is PdfDashboardWidgetId =>
|
|
||||||
PDF_DASHBOARD_WIDGETS.some((w) => w.id === id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (t.name) setTitle(t.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="export-title">Title</Label>
|
|
||||||
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{/* Date-range filter. Drives every time-period section
|
|
||||||
(new clients in window, berths sold in window, occupancy
|
|
||||||
timeline, etc.). Defaulted to the last 30 days so a
|
|
||||||
first-time export already has a sensible window without
|
|
||||||
the rep configuring anything. Sections that don't
|
|
||||||
require a window (KPIs, current pipeline funnel, etc.)
|
|
||||||
ignore it. */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Report window</Label>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<DatePicker
|
|
||||||
id="export-date-from"
|
|
||||||
value={dateFrom}
|
|
||||||
onChange={setDateFrom}
|
|
||||||
placeholder="Start"
|
|
||||||
size="sm"
|
|
||||||
className="w-[150px]"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">→</span>
|
|
||||||
<DatePicker
|
|
||||||
id="export-date-to"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={setDateTo}
|
|
||||||
placeholder="End"
|
|
||||||
size="sm"
|
|
||||||
className="w-[150px]"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const t = new Date();
|
|
||||||
const start = new Date(t);
|
|
||||||
start.setDate(start.getDate() - 30);
|
|
||||||
setDateFrom(toIsoLocal(start));
|
|
||||||
setDateTo(toIsoLocal(t));
|
|
||||||
}}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
Last 30 days
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const t = new Date();
|
|
||||||
const start = new Date(t);
|
|
||||||
start.setDate(start.getDate() - 90);
|
|
||||||
setDateFrom(toIsoLocal(start));
|
|
||||||
setDateTo(toIsoLocal(t));
|
|
||||||
}}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
Last 90 days
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Drives time-period sections (new clients, berths sold, occupancy timeline, etc.).
|
|
||||||
Sections marked “needs date range” only render when both dates are set.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Sections</Label>
|
|
||||||
{/* Grouped checkbox list. Each widget knows its own
|
|
||||||
category; we render the categories in PDF_DASHBOARD_-
|
|
||||||
CATEGORY_LABELS' declared order so charts surface
|
|
||||||
before tables surface before period cohorts. */}
|
|
||||||
<div className="max-h-[50vh] space-y-3 overflow-y-auto rounded-md border p-2">
|
|
||||||
{(
|
|
||||||
Object.entries(PDF_DASHBOARD_CATEGORY_LABELS) as Array<
|
|
||||||
[PdfDashboardWidgetCategory, string]
|
|
||||||
>
|
|
||||||
).map(([category, label]) => {
|
|
||||||
const items = PDF_DASHBOARD_WIDGETS.filter((w) => w.category === category);
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={category} className="space-y-1">
|
|
||||||
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{items.map((w) => (
|
|
||||||
<label
|
|
||||||
key={w.id}
|
|
||||||
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.includes(w.id)}
|
|
||||||
onCheckedChange={() => toggle(w.id)}
|
|
||||||
aria-label={w.label}
|
|
||||||
/>
|
|
||||||
<div className="text-sm leading-tight">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-medium">{w.label}</span>
|
|
||||||
{w.isChart ? (
|
|
||||||
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-primary">
|
|
||||||
chart
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{w.requiresPeriod ? (
|
|
||||||
<span className="shrink-0 whitespace-nowrap rounded-full bg-amber-100 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-amber-800">
|
|
||||||
needs date range
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{w.description}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setPreviewOpen(true)}
|
|
||||||
disabled={loading || selected.length === 0}
|
|
||||||
>
|
|
||||||
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
Download PDF
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{previewOpen ? (
|
|
||||||
<PdfPreviewModal
|
|
||||||
open
|
|
||||||
onOpenChange={setPreviewOpen}
|
|
||||||
payload={previewPayload}
|
|
||||||
filename={`${title.trim().replace(/[\\/]/g, '_') || 'report'}.pdf`}
|
|
||||||
title={`Preview: ${title.trim() || 'Report'}`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
160
src/components/reports/sub-pages/report-runs-page-client.tsx
Normal file
160
src/components/reports/sub-pages/report-runs-page-client.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Download, Mail, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import type { ReportRun } from '@/lib/db/schema/reports';
|
||||||
|
|
||||||
|
interface ListResponse {
|
||||||
|
data: ReportRun[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
pending: 'secondary',
|
||||||
|
rendering: 'secondary',
|
||||||
|
complete: 'default',
|
||||||
|
failed: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportRunsPageClient({ portSlug }: { portSlug: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
|
queryKey: ['report-runs'],
|
||||||
|
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/runs?limit=50&order=desc'),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// Auto-poll while any row is in flight so the rep sees status flip
|
||||||
|
// without manual refresh.
|
||||||
|
const rows = query.state.data?.data ?? [];
|
||||||
|
return rows.some((r) => r.status === 'pending' || r.status === 'rendering') ? 5_000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rerunMutation = useMutation({
|
||||||
|
mutationFn: async (run: ReportRun) => {
|
||||||
|
return apiFetch('/api/v1/reports/runs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
kind: run.kind,
|
||||||
|
config: run.config,
|
||||||
|
outputFormat: run.outputFormat,
|
||||||
|
...(run.templateId ? { templateId: run.templateId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Re-run queued');
|
||||||
|
qc.invalidateQueries({ queryKey: ['report-runs'] });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Re-run failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Runs"
|
||||||
|
description="Every report generated for this port. In-flight runs auto-refresh."
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-[200px] w-full" aria-hidden />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No runs yet"
|
||||||
|
description="Generate a report from the Reports landing page to see it here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Kind</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Triggered</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Output</TableHead>
|
||||||
|
<TableHead className="w-32 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium capitalize">{r.kind}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status] ?? 'outline'}>{r.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground capitalize">
|
||||||
|
{r.triggeredBy}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(r.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{r.outputFormat}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{r.status === 'complete' && r.storageKey ? (
|
||||||
|
<Button asChild size="sm" variant="ghost" title="Download artefact">
|
||||||
|
<Link href={`/api/v1/files/${r.storageKey}/download` as Route}>
|
||||||
|
<Download className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="Re-run with the same config"
|
||||||
|
onClick={() => rerunMutation.mutate(r)}
|
||||||
|
disabled={rerunMutation.isPending}
|
||||||
|
>
|
||||||
|
{rerunMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Calendar } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import type { ReportSchedule } from '@/lib/db/schema/reports';
|
||||||
|
|
||||||
|
interface ListResponse {
|
||||||
|
data: ReportSchedule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CADENCE_LABELS: Record<string, string> = {
|
||||||
|
weekly_monday_9: 'Weekly · Monday 9am',
|
||||||
|
monthly_first_9: 'Monthly · 1st @ 9am',
|
||||||
|
quarterly_first_9: 'Quarterly · 1st @ 9am',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
|
queryKey: ['report-schedules'],
|
||||||
|
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/schedules?limit=50'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||||
|
return apiFetch(`/api/v1/reports/schedules/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { enabled },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Schedule updated');
|
||||||
|
qc.invalidateQueries({ queryKey: ['report-schedules'] });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Schedules"
|
||||||
|
description="Recurring reports auto-emailed to your recipient list."
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-[200px] w-full" aria-hidden />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Calendar}
|
||||||
|
title="No schedules yet"
|
||||||
|
description="Save a template, then schedule it from the template detail page."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Cadence</TableHead>
|
||||||
|
<TableHead>Recipients</TableHead>
|
||||||
|
<TableHead>Last run</TableHead>
|
||||||
|
<TableHead>Next run</TableHead>
|
||||||
|
<TableHead>Output</TableHead>
|
||||||
|
<TableHead className="w-20 text-right">Enabled</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{CADENCE_LABELS[s.cadence] ?? s.cadence}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{Array.isArray(s.recipients) ? s.recipients.length : 0}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(s.nextRunAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{s.outputFormat}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Switch
|
||||||
|
checked={s.enabled}
|
||||||
|
onCheckedChange={(enabled) => toggleMutation.mutate({ id: s.id, enabled })}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, FilePlus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import type { ReportTemplate } from '@/lib/db/schema/reports';
|
||||||
|
|
||||||
|
interface ListResponse {
|
||||||
|
data: ReportTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportTemplatesPageClient({ portSlug }: { portSlug: string }) {
|
||||||
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
|
queryKey: ['report-templates'],
|
||||||
|
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/templates?limit=50'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Reports"
|
||||||
|
title="Templates"
|
||||||
|
description="Saved report configurations. Pick one to re-run with one click."
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-[200px] w-full" aria-hidden />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={FilePlus}
|
||||||
|
title="No saved templates yet"
|
||||||
|
description="When generating a report from a builder, save the configuration to re-use it."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Kind</TableHead>
|
||||||
|
<TableHead>Visibility</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/reports/${t.kind}?templateId=${t.id}` as Route}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
|
{t.description ? (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{t.description}</p>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{t.kind}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={t.visibility === 'team' ? 'default' : 'outline'}>
|
||||||
|
{t.visibility}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user