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