diff --git a/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx b/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx new file mode 100644 index 00000000..f0c33f60 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx @@ -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 = { + 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 ( +
+ + + + All reports + + + } + /> + + {typedKind === 'dashboard' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/reports/page.tsx b/src/app/(dashboard)/[portSlug]/reports/page.tsx index 3838ba18..3a0120cf 100644 --- a/src/app/(dashboard)/[portSlug]/reports/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reports/page.tsx @@ -1,5 +1,163 @@ +import Link from 'next/link'; +import type { Route } from 'next'; +import { ArrowRight, BarChart3, Calendar, Clock, FileText, Layers, Users } from 'lucide-react'; + +import { PageHeader } from '@/components/shared/page-header'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ReportsPageClient } from '@/components/reports/reports-page-client'; -export default function ReportsPage() { - return ; +interface PageProps { + params: Promise<{ portSlug: string }>; +} + +interface KindCard { + kind: 'dashboard' | 'clients' | 'berths' | 'interests'; + title: string; + description: string; + icon: typeof BarChart3; +} + +const KINDS: KindCard[] = [ + { + kind: 'dashboard', + title: 'Dashboard report', + description: + 'Multi-section PDF of the port dashboard — pipeline funnel, occupancy timeline, KPIs, lead sources.', + icon: BarChart3, + }, + { + kind: 'clients', + title: 'Clients report', + description: 'Activity snapshot across every active client in a date window.', + icon: Users, + }, + { + kind: 'berths', + title: 'Berths report', + description: 'Occupancy + status mix for every berth across the requested window.', + icon: Layers, + }, + { + kind: 'interests', + title: 'Interests report', + description: 'Pipeline value + stage distribution for every interest.', + icon: FileText, + }, +]; + +const SUB_PAGES: Array<{ + href: string; + label: string; + description: string; + icon: typeof BarChart3; +}> = [ + { + href: '/reports/templates', + label: 'Templates', + description: 'Saved configurations reps can re-run with one click.', + icon: Layers, + }, + { + href: '/reports/runs', + label: 'Runs', + description: 'Every report you have generated, with re-run and re-email links.', + icon: Clock, + }, + { + href: '/reports/schedules', + label: 'Schedules', + description: 'Recurring reports that auto-email to your recipient list.', + icon: Calendar, + }, +]; + +export default async function ReportsLandingPage({ params }: PageProps) { + const { portSlug } = await params; + + return ( +
+ + +
+

+ Build a new report +

+
+ {KINDS.map((k) => { + const Icon = k.icon; + return ( + +
+ +
+
+

{k.title}

+ +
+

{k.description}

+ + ); + })} +
+
+ +
+

+ Library +

+
+ {SUB_PAGES.map((s) => { + const Icon = s.icon; + return ( + +
+ +
+
+

{s.label}

+ +
+

{s.description}

+ + ); + })} +
+
+ +
+

+ Legacy library +

+ + + Older reports + ad-hoc generator + + Pre-P4 reports surface. Stays available so historical PDFs are still downloadable + while the new template / run / schedule surfaces fill in. + + + + + + +
+
+ ); } diff --git a/src/app/(dashboard)/[portSlug]/reports/runs/page.tsx b/src/app/(dashboard)/[portSlug]/reports/runs/page.tsx new file mode 100644 index 00000000..0f004953 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/runs/page.tsx @@ -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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx b/src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx new file mode 100644 index 00000000..248f7670 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/schedules/page.tsx @@ -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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/reports/templates/page.tsx b/src/app/(dashboard)/[portSlug]/reports/templates/page.tsx new file mode 100644 index 00000000..25de0971 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/templates/page.tsx @@ -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 ; +} diff --git a/src/components/reports/builders/dashboard-report-builder.tsx b/src/components/reports/builders/dashboard-report-builder.tsx new file mode 100644 index 00000000..f22ad3fc --- /dev/null +++ b/src/components/reports/builders/dashboard-report-builder.tsx @@ -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( + 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 { + 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 ( +
+ { + 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); + }} + /> + + + + Title + window + + +
+ + setTitle(e.target.value)} + className="max-w-md" + /> +
+
+ +
+ + + + + +
+

+ Drives time-period sections (new clients, berths sold, occupancy timeline, etc.). + Sections marked “needs date range” only render when both dates are set. +

+
+
+
+ + + + Sections + + +
+ {( + 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 ( +
+
+ {label} +
+
+ {items.map((w) => ( + + ))} +
+
+ ); + })} +
+
+
+ +
+ + + +
+ + {previewOpen ? ( + + ) : null} +
+ ); +} diff --git a/src/components/reports/builders/simple-report-builder.tsx b/src/components/reports/builders/simple-report-builder.tsx new file mode 100644 index 00000000..7dac7df3 --- /dev/null +++ b/src/components/reports/builders/simple-report-builder.tsx @@ -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 = { + 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 ( + + + {labels.title} + + +

{labels.description}

+
+ +
+ + + +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/reports/export-dashboard-pdf-button.tsx b/src/components/reports/export-dashboard-pdf-button.tsx index 91f92ea3..85410062 100644 --- a/src/components/reports/export-dashboard-pdf-button.tsx +++ b/src/components/reports/export-dashboard-pdf-button.tsx @@ -1,41 +1,15 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { Eye, FileDown, Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import type { Route } from 'next'; +import { FileDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; 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 { resolvePortIdFromSlug } from '@/lib/api/client'; 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 { const y = d.getFullYear(); 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 - * pick which sections to include + set a custom title. Saved-template - * support lands in Phase C; for now, the dialog defaults all widgets - * checked + the current date in the title. + * Dashboard "Export as PDF" affordance. As of Reports P4 this navigates + * to the dedicated `/reports/dashboard` builder page (carrying the + * currently-active range through `?from=YYYY-MM-DD&to=YYYY-MM-DD`) + * instead of opening an in-dashboard dialog. The dialog body now lives + * in `DashboardReportBuilder`. * * Permission-gated client-side on `reports.export`; the server route * re-checks via withPermission so a tampered client can't bypass. @@ -57,300 +32,36 @@ export function ExportDashboardPdfButton({ initialRange, }: { className?: string; - /** The dashboard's currently-active range. When supplied, drives the - * dialog's initial dateFrom / dateTo so the rep doesn't re-pick a - * range they just chose on the dashboard. Falls back to last 30 days - * when omitted (still useful for ad-hoc reports). */ + /** Carried through to the builder so the rep doesn't re-pick a range + * they just chose on the dashboard. */ initialRange?: DateRange; } = {}) { const { can } = usePermissions(); - const [open, setOpen] = useState(false); - const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString(undefined)}`); - const [selected, setSelected] = useState( - 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], - ); + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; if (!can('reports', 'export')) return null; + if (!portSlug) return null; - function toggle(id: PdfDashboardWidgetId) { - setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); - } - - async function handleExport() { - if (selected.length === 0) { - 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); - } - } + const search = (() => { + if (!initialRange) return ''; + const { from, to } = rangeToBounds(initialRange); + return `?from=${toIsoLocal(from)}&to=${toIsoLocal(to)}`; + })(); + const href = `/${portSlug}/reports/dashboard${search}` as Route; return ( - <> - - - - - Export dashboard as PDF - - Pick which sections to include and set a title. The PDF inherits the active - port's logo and primary color. - - -
- { - 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); - }} - /> -
- - setTitle(e.target.value)} /> -
- {/* 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. */} -
- -
- - - - - -
-

- Drives time-period sections (new clients, berths sold, occupancy timeline, etc.). - Sections marked “needs date range” only render when both dates are set. -

-
-
- - {/* 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. */} -
- {( - 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 ( -
-
- {label} -
-
- {items.map((w) => ( - - ))} -
-
- ); - })} -
-
-
- - - - - -
-
- {previewOpen ? ( - - ) : null} - + + ); } diff --git a/src/components/reports/sub-pages/report-runs-page-client.tsx b/src/components/reports/sub-pages/report-runs-page-client.tsx new file mode 100644 index 00000000..01811e5a --- /dev/null +++ b/src/components/reports/sub-pages/report-runs-page-client.tsx @@ -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 = { + pending: 'secondary', + rendering: 'secondary', + complete: 'default', + failed: 'destructive', +}; + +export function ReportRunsPageClient({ portSlug }: { portSlug: string }) { + const qc = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['report-runs'], + queryFn: () => apiFetch('/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 ( +
+ + + + All reports + + + } + /> + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + + + Kind + Status + Triggered + Created + Output + Actions + + + + {rows.map((r) => ( + + {r.kind} + + {r.status} + + + {r.triggeredBy} + + + {new Date(r.createdAt).toLocaleString()} + + + {r.outputFormat} + + +
+ {r.status === 'complete' && r.storageKey ? ( + + ) : null} + +
+
+
+ ))} +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/reports/sub-pages/report-schedules-page-client.tsx b/src/components/reports/sub-pages/report-schedules-page-client.tsx new file mode 100644 index 00000000..6a214453 --- /dev/null +++ b/src/components/reports/sub-pages/report-schedules-page-client.tsx @@ -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 = { + 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({ + queryKey: ['report-schedules'], + queryFn: () => apiFetch('/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 ( +
+ + + + All reports + + + } + /> + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + + + Cadence + Recipients + Last run + Next run + Output + Enabled + + + + {rows.map((s) => ( + + + {CADENCE_LABELS[s.cadence] ?? s.cadence} + + + + {Array.isArray(s.recipients) ? s.recipients.length : 0} + + + + {s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'} + + + {new Date(s.nextRunAt).toLocaleString()} + + + {s.outputFormat} + + + toggleMutation.mutate({ id: s.id, enabled })} + disabled={toggleMutation.isPending} + /> + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/src/components/reports/sub-pages/report-templates-page-client.tsx b/src/components/reports/sub-pages/report-templates-page-client.tsx new file mode 100644 index 00000000..0dc7bfbf --- /dev/null +++ b/src/components/reports/sub-pages/report-templates-page-client.tsx @@ -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({ + queryKey: ['report-templates'], + queryFn: () => apiFetch('/api/v1/reports/templates?limit=50'), + }); + + const rows = data?.data ?? []; + + return ( +
+ + + + All reports + + + } + /> + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + + + Name + Kind + Visibility + Created + + + + {rows.map((t) => ( + + + + {t.name} + + {t.description ? ( +

{t.description}

+ ) : null} +
+ {t.kind} + + + {t.visibility} + + + + {new Date(t.createdAt).toLocaleDateString()} + +
+ ))} +
+
+
+
+ )} +
+ ); +}