Wave A (Interest+EOI form quick wins): - Auto-select yacht after inline-create from interest form - EOI generate dialog: "View EOI" action toast - Interest form berth picker: formatBerthRange compact label - Remove "Generate EOI" button from Documents tab (clean removal) - Interest auto-assign: only sales_agent/sales_manager auto-claim ownership on create (explicit role check via user_port_roles join) - LinkedBerthRowItem dims: drop "D" suffix + "L × W" format - ExternalEoiUploadDialog: prefillSignatories prop threaded from active EOI signers - EOI signature progress on Overview milestone card footer Wave B (a11y + i18n sweeps): - aria-live on supplemental-info error state - text-[10px] -> text-xs in client-pipeline-summary - Currency formatter: locale default removed (Intl uses runtime) - en-US/en-GB hardcoded toLocaleString swept across 13 components Wave C (Primary berth always in EOI bundle): - Service guard strengthened on update path - Migration 0083 backfills historical primary rows Wave D (Onboarding super_admin discoverability): - /api/v1/admin/onboarding/status endpoint + shared service - Topbar OnboardingBanner (super_admin, session-dismissible) - OnboardingTile dashboard widget (rail group, self-hides at 100%) - Celebration toast + invalidate of shared status on last tick Wave E (Branded post-completion email idempotency): - Verified handleDocumentCompleted already owns the email fan-out - Added regression test for the polling path + idempotency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
'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<PdfDashboardWidgetId[]>(
|
|
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 (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setOpen(true)}
|
|
title="Export dashboard as PDF"
|
|
aria-label="Export dashboard as PDF"
|
|
className={cn('h-9 w-9 p-0 text-muted-foreground hover:text-foreground', className)}
|
|
>
|
|
<FileDown className="h-4 w-4" aria-hidden />
|
|
</Button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Export dashboard as PDF</DialogTitle>
|
|
<DialogDescription>
|
|
Pick which sections to include and set a title. The PDF inherits the active
|
|
port's logo and primary color.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<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);
|
|
}}
|
|
/>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="export-title">Title</Label>
|
|
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
</div>
|
|
{/* 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. */}
|
|
<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>
|
|
<div className="space-y-2">
|
|
<Label>Sections</Label>
|
|
{/* 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. */}
|
|
<div className="max-h-[50vh] 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>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setPreviewOpen(true)}
|
|
disabled={loading || selected.length === 0}
|
|
>
|
|
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
|
|
Preview
|
|
</Button>
|
|
<Button onClick={handleExport} disabled={loading || 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>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{previewOpen ? (
|
|
<PdfPreviewModal
|
|
open
|
|
onOpenChange={setPreviewOpen}
|
|
payload={previewPayload}
|
|
filename={`${title.trim().replace(/[\\/]/g, '_') || 'report'}.pdf`}
|
|
title={`Preview: ${title.trim() || 'Report'}`}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|