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>
2026-05-25 16:28:26 +02:00
|
|
|
'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">
|
2026-06-03 22:34:47 +02:00
|
|
|
<Link
|
|
|
|
|
href={`/api/v1/files/${r.storageKey}/download?redirect=1` as Route}
|
|
|
|
|
>
|
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>
2026-05-25 16:28:26 +02:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|