'use client'; import { useMemo, useState } from 'react'; import { Eye, FileDown, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; 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'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** * 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. * * Permission-gated client-side on `reports.export`; the server route * re-checks via withPermission so a tampered client can't bypass. */ export function ExportDashboardPdfButton({ className, 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). */ 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], ); if (!can('reports', 'export')) 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); } } 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} ); }