'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 [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf'); 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, }), }); 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.

{(['pdf', 'csv'] as const).map((f) => ( ))}

PDF carries the brand kit + section layout; CSV emits a flat metric-per-row dump suitable for spreadsheet analysis. CSV applies to the queued-run path only.

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}
); }