feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports

End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View File

@@ -0,0 +1,408 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Download, FileText, Loader2, Play, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
import { formatMoney, formatNumber } from '@/lib/reports/format-currency';
/**
* Map from money-amount column → adjacent currency column. When both
* are selected the on-screen + CSV output formats the amount with the
* row's currency. When only the amount is selected we still pretty-
* print with thousand separators but skip the currency glyph (the
* analyst presumably has context elsewhere).
*/
const MONEY_COLUMN_PAIRS: Record<string, string> = {
price: 'priceCurrency',
depositExpectedAmount: 'depositExpectedCurrency',
};
function isMoneyColumnKey(key: string): boolean {
return key in MONEY_COLUMN_PAIRS;
}
interface RunResponse {
data: Array<Record<string, unknown>>;
meta: {
entity: EntityKey;
columns: Array<{ key: string; label: string }>;
rowCount: number;
};
}
interface CustomTemplateConfig extends Record<string, unknown> {
kind: 'custom';
entity: EntityKey;
columns: string[];
from?: string;
to?: string;
}
function defaultColumnsFor(entity: EntityKey): string[] {
return ENTITY_REGISTRY[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key);
}
export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
const [entity, setEntity] = useState<EntityKey>('clients');
const [columns, setColumns] = useState<string[]>(defaultColumnsFor('clients'));
const [from, setFrom] = useState<string>('');
const [to, setTo] = useState<string>('');
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
const [columnLabels, setColumnLabels] = useState<Array<{ key: string; label: string }>>([]);
// When the user picks a different entity, reset columns to the
// entity's defaults (carrying forward column keys would be confusing
// since they're entity-specific). Also clear the active template
// badge since the rep is composing a new query.
function handleEntityChange(next: EntityKey) {
setEntity(next);
setColumns(defaultColumnsFor(next));
setRows([]);
setColumnLabels([]);
setActiveTemplateId(null);
}
function toggleColumn(key: string, checked: boolean) {
setColumns((prev) => {
if (checked) return prev.includes(key) ? prev : [...prev, key];
return prev.filter((k) => k !== key);
});
setActiveTemplateId(null);
}
const handleFromChange = useCallback((next: string) => {
setFrom(next);
setActiveTemplateId(null);
}, []);
const handleToChange = useCallback((next: string) => {
setTo(next);
setActiveTemplateId(null);
}, []);
const currentConfig: CustomTemplateConfig = useMemo(
() => ({
kind: 'custom',
entity,
columns,
from: from || undefined,
to: to || undefined,
}),
[entity, columns, from, to],
);
const handleApplyTemplate = useCallback((config: CustomTemplateConfig) => {
// Raw setters: template apply MUST NOT clear the active-template
// badge that the user-facing handlers above clear.
if (config.entity) setEntity(config.entity);
if (config.columns) setColumns(config.columns);
setFrom(config.from ?? '');
setTo(config.to ?? '');
setRows([]);
setColumnLabels([]);
}, []);
const runMutation = useMutation({
mutationFn: async () => {
// Convert the date-only YYYY-MM-DD strings (DatePicker output)
// into ISO-8601 boundaries so the API zod schema accepts them.
const fromIso = from ? new Date(`${from}T00:00:00.000Z`).toISOString() : undefined;
const toIso = to ? new Date(`${to}T23:59:59.999Z`).toISOString() : undefined;
return apiFetch<RunResponse>(`/api/v1/reports/custom/run`, {
method: 'POST',
body: {
entity,
columns,
from: fromIso,
to: toIso,
},
});
},
onSuccess: (res) => {
setRows(res.data);
setColumnLabels(res.meta.columns);
toast.success(`Loaded ${res.meta.rowCount} rows`);
},
onError: (err) => toastError(err),
});
function downloadCsv() {
if (rows.length === 0) {
toast.error('Run the query first');
return;
}
const headerLabels = columnLabels.map((c) => csvCell(c.label));
const lines = [headerLabels.join(',')];
for (const row of rows) {
const cells = columnLabels.map((c) => csvCell(formatCellValue(c.key, row)));
lines.push(cells.join(','));
}
const filenameSlug = `custom-${entity}`;
const dateSuffix = new Date().toISOString().slice(0, 10);
const filename = `${filenameSlug}-${dateSuffix}.csv`;
const bom = '';
const blob = new Blob([bom + lines.join('\r\n') + '\r\n'], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast.success(`Downloaded ${filename}`);
}
const def = ENTITY_REGISTRY[entity];
return (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Custom report"
description="Pick an entity, choose columns, set an optional date range, download as CSV. Save the configuration as a template to re-run later."
actions={
<div className="flex items-center gap-2">
<ReportTemplatesButton<CustomTemplateConfig>
kind="custom"
currentConfig={currentConfig}
onApply={handleApplyTemplate}
activeTemplateId={activeTemplateId}
onActiveTemplateChange={setActiveTemplateId}
initialTemplateId={initialTemplateId}
/>
</div>
}
/>
<Card>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[260px_1fr] lg:items-start">
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-entity" className="text-xs">
Entity
</Label>
<Select value={entity} onValueChange={(v) => handleEntityChange(v as EntityKey)}>
<SelectTrigger id="custom-entity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTITY_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ENTITY_REGISTRY[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{def.description}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Date filter ({def.dateAxis})</Label>
<div className="space-y-1.5">
<DatePicker
id="custom-date-from"
value={from}
onChange={handleFromChange}
placeholder="From"
size="sm"
/>
<DatePicker
id="custom-date-to"
value={to}
onChange={handleToChange}
placeholder="To"
size="sm"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Optional. Leave blank for all-time.
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
onClick={() => runMutation.mutate()}
disabled={runMutation.isPending || columns.length === 0}
size="sm"
>
{runMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Play className="mr-1.5 h-4 w-4" aria-hidden />
)}
Run query
</Button>
<Button
onClick={downloadCsv}
variant="outline"
size="sm"
disabled={rows.length === 0}
>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
Download CSV
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Columns</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{def.columns.map((c) => {
const checked = columns.includes(c.key);
return (
<label
key={c.key}
className="flex cursor-pointer items-center gap-2 rounded-md border bg-muted/20 px-2 py-1.5 text-sm hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleColumn(c.key, Boolean(v))}
/>
<span>{c.label}</span>
</label>
);
})}
</div>
{columns.length === 0 ? (
<p className="text-xs text-amber-600">Select at least one column to run.</p>
) : (
<p className="text-[11px] text-muted-foreground">
{columns.length} of {def.columns.length} columns selected.
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Results table — only shows after Run query. Caps the visible
rows; CSV export gives the full set. */}
{rows.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2 text-xs">
<span className="font-medium">{rows.length} rows</span>
<span className="text-muted-foreground">
Showing first {Math.min(rows.length, 50)} · download CSV for full set
</span>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columnLabels.map((c) => (
<TableHead key={c.key}>{c.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 50).map((row, idx) => (
<TableRow key={idx}>
{columnLabels.map((c) => (
<TableCell key={c.key} className="text-sm">
{formatCellValue(c.key, row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : runMutation.isSuccess ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">No rows match this query</p>
<p className="mt-1 text-xs text-muted-foreground">
Try widening the date range, picking a different entity, or removing filters.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">Configure your query above, then Run.</p>
<p className="mt-1 text-xs text-muted-foreground">
Results appear here. Save the configuration as a template to schedule recurring runs
or share it with the team.
</p>
</CardContent>
</Card>
)}
</div>
);
}
/**
* Per-cell value formatter. Falls through to a generic string render
* except for known money columns (per MONEY_COLUMN_PAIRS) where we
* pretty-print with the row's currency when available. Numeric columns
* outside the money set get thousand-separator formatting for
* readability.
*/
function formatCellValue(key: string, row: Record<string, unknown>): string {
const v = row[key];
if (v === null || v === undefined) return '';
if (isMoneyColumnKey(key) && typeof v === 'number') {
const currencyKey = MONEY_COLUMN_PAIRS[key];
if (currencyKey) {
const ccy = row[currencyKey];
if (typeof ccy === 'string' && ccy.length > 0) return formatMoney(v, ccy);
}
// Currency unknown — drop the glyph, keep the readable number.
return formatNumber(v);
}
if (v instanceof Date) return v.toISOString().slice(0, 10);
if (typeof v === 'number') return formatNumber(v);
if (typeof v === 'string') {
if (/^\d{4}-\d{2}-\d{2}T/.test(v)) return v.slice(0, 10);
return v;
}
return String(v);
}
function csvCell(value: string): string {
if (value === '') return '""';
return `"${value.replace(/"/g, '""')}"`;
}

View File

@@ -0,0 +1,129 @@
'use client';
/**
* Operational — Berth utilisation heatmap (Report 04 Chart 1).
*
* Pure CSS grid (no chart library) — each cell coloured by occupancy %.
* Months across the X-axis (most recent on the right), areas down the
* Y-axis. Hover shows the occupancy % and underlying count.
*/
import { cn } from '@/lib/utils';
interface UtilisationCell {
area: string;
month: string;
occupancyPct: number;
}
interface Props {
cells: UtilisationCell[];
}
export function OperationalHeatmap({ cells }: Props) {
if (cells.length === 0) {
return (
<div className="py-10 text-center text-sm text-muted-foreground">
No berth history captured yet. The heatmap fills in as status changes accumulate.
</div>
);
}
// Build the unique area + month axes
const areas = Array.from(new Set(cells.map((c) => c.area))).sort();
const months = Array.from(new Set(cells.map((c) => c.month))).sort();
// Build a lookup so we can render in O(1) per cell
const byKey = new Map<string, UtilisationCell>();
for (const c of cells) byKey.set(`${c.area}|${c.month}`, c);
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `120px repeat(${months.length}, 1fr)` }}
>
{/* Header row: month labels */}
<div />
{months.map((m) => (
<div
key={m}
className="text-[10px] text-muted-foreground text-center font-mono"
style={{ writingMode: months.length > 18 ? 'vertical-rl' : undefined }}
>
{formatMonth(m)}
</div>
))}
{/* Body */}
{areas.map((area) => (
<FragmentRow key={area} area={area} months={months} byKey={byKey} />
))}
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span>Occupancy:</span>
<div className="flex items-center gap-0.5">
{[0, 20, 40, 60, 80, 100].map((pct) => (
<div key={pct} className={cn('h-3 w-6', colorForPct(pct))} title={`${pct}%`} />
))}
</div>
<span>0% 100%</span>
</div>
</div>
</div>
);
}
function FragmentRow({
area,
months,
byKey,
}: {
area: string;
months: string[];
byKey: Map<string, UtilisationCell>;
}) {
return (
<>
<div className="text-xs font-medium text-foreground truncate pr-2 self-center">{area}</div>
{months.map((month) => {
const cell = byKey.get(`${area}|${month}`);
const pct = cell?.occupancyPct ?? 0;
return (
<div
key={`${area}|${month}`}
className={cn('h-7 rounded-sm transition-colors', colorForPct(pct))}
title={`${area} · ${formatMonthLong(month)}: ${pct.toFixed(0)}%`}
/>
);
})}
</>
);
}
function colorForPct(pct: number): string {
// 6-step ramp using the existing brand palette
if (pct >= 90) return 'bg-brand-700';
if (pct >= 70) return 'bg-brand-500';
if (pct >= 50) return 'bg-brand-300';
if (pct >= 30) return 'bg-brand-100';
if (pct > 0) return 'bg-brand-50';
return 'bg-muted/30';
}
function formatMonth(month: string): string {
const [year, m] = month.split('-');
if (!year || !m) return month;
const d = new Date(parseInt(year), parseInt(m) - 1, 1);
return d.toLocaleDateString(undefined, { month: 'short' });
}
function formatMonthLong(month: string): string {
const [year, m] = month.split('-');
if (!year || !m) return month;
const d = new Date(parseInt(year), parseInt(m) - 1, 1);
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
'use client';
/**
* Operational — Signing turnaround box plot (Report 04 Chart 5).
*
* Pure CSS / SVG-free box plot. Each row is one document type; the
* horizontal bar shows min-Q1-median-Q3-max distribution. Simpler +
* lighter than echarts' boxplot and renders identically in PDF.
*/
import { cn } from '@/lib/utils';
interface SigningBoxPlot {
documentType: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
sampleSize: number;
}
interface Props {
rows: SigningBoxPlot[];
}
const TYPE_COLOR: Record<string, string> = {
eoi: 'bg-brand-300',
reservation_agreement: 'bg-brand-500',
contract: 'bg-brand-700',
};
export function OperationalSigningBoxPlot({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-10 text-center text-sm text-muted-foreground">
No completed documents yet. The distribution fills in once documents complete their full
signing cycle.
</div>
);
}
// Universal scale across all rows so types are visually comparable
const max = Math.max(1, ...rows.map((r) => r.max));
return (
<div className="space-y-3">
{rows.map((row) => {
const color = TYPE_COLOR[row.documentType] ?? 'bg-brand-500';
return (
<div
key={row.documentType}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '160px 1fr 120px' }}
>
<div className="text-sm font-medium text-foreground">
{formatType(row.documentType)}
</div>
{/* Box plot rendered with CSS:
- whisker line: min → max (faint)
- box: Q1 → Q3 (brand color)
- median tick inside box (white) */}
<div className="relative h-8 rounded-sm bg-muted/20">
{/* Whisker (min to max) */}
<div
className="absolute top-1/2 -translate-y-1/2 h-px bg-foreground/40"
style={{
left: `${(row.min / max) * 100}%`,
width: `${((row.max - row.min) / max) * 100}%`,
}}
/>
{/* Min cap */}
<div
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-foreground/60"
style={{ left: `${(row.min / max) * 100}%` }}
/>
{/* Max cap */}
<div
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-foreground/60"
style={{ left: `${(row.max / max) * 100}%` }}
/>
{/* Box (Q1 to Q3) */}
<div
className={cn('absolute top-1 bottom-1 rounded-sm', color)}
style={{
left: `${(row.q1 / max) * 100}%`,
width: `${((row.q3 - row.q1) / max) * 100}%`,
}}
title={`Q1: ${row.q1.toFixed(1)}d, Q3: ${row.q3.toFixed(1)}d`}
/>
{/* Median tick */}
<div
className="absolute top-1 bottom-1 w-0.5 bg-white"
style={{ left: `${(row.median / max) * 100}%` }}
title={`Median: ${row.median.toFixed(1)}d`}
/>
</div>
<div className="text-[11px] text-muted-foreground tabular-nums text-right">
<span className="font-medium text-foreground">{row.median.toFixed(1)}d</span> median
<br />
<span className="text-muted-foreground/80">n={row.sampleSize}</span>
</div>
</div>
);
})}
{/* X-axis tick reference */}
<div className="grid pt-2" style={{ gridTemplateColumns: '160px 1fr 120px' }}>
<div />
<div className="relative h-4">
<span className="absolute left-0 text-[10px] text-muted-foreground">0d</span>
<span className="absolute right-0 text-[10px] text-muted-foreground">
{max.toFixed(0)}d
</span>
</div>
<div />
</div>
</div>
);
}
function formatType(t: string): string {
return t
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.replace(/Eoi/i, 'EOI');
}

View File

@@ -0,0 +1,160 @@
'use client';
/**
* Sales Performance — Deal heat section (between leaderboard + tables).
*
* Three things in one section:
* 1. Hot deals count (KPI tile)
* 2. Heat distribution mini-chart (3-segment horizontal bar)
* 3. Hottest deals right now (top 5 table)
*
* Pulls from /api/v1/reports/sales `dealHeat`. Heat semantics defined
* in the service (sales.service.ts § getDealHeat).
*/
import Link from 'next/link';
import type { Route } from 'next';
import { Flame } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
import { useUIStore } from '@/stores/ui-store';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
type HeatBucket = 'hot' | 'warm' | 'cold';
interface DealHeatSummary {
distribution: Record<HeatBucket, number>;
topDeals: Array<{
id: string;
clientName: string;
mooringNumber: string | null;
stage: PipelineStage;
bucket: HeatBucket;
daysSinceLastContact: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
}>;
}
interface Props {
data: DealHeatSummary;
}
const HEAT_LABEL: Record<HeatBucket, string> = { hot: 'Hot', warm: 'Warm', cold: 'Cold' };
const HEAT_COLOR: Record<HeatBucket, string> = {
hot: 'bg-rose-500',
warm: 'bg-amber-400',
cold: 'bg-slate-400',
};
const HEAT_BADGE: Record<HeatBucket, string> = {
hot: 'bg-rose-100 text-rose-800',
warm: 'bg-amber-100 text-amber-800',
cold: 'bg-slate-100 text-slate-700',
};
export function SalesDealHeat({ data }: Props) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
const total = data.distribution.hot + data.distribution.warm + data.distribution.cold;
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* Hot deals tile + distribution bar (lg col-span 1) */}
<Card className="p-4 lg:col-span-1 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Hot deals right now
</p>
<Flame className="h-4 w-4 text-rose-500" aria-hidden />
</div>
<div className="flex items-baseline gap-1.5">
<p className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
{data.distribution.hot}
</p>
<p className="text-xs text-muted-foreground">
of {total} active {total === 1 ? 'deal' : 'deals'}
</p>
</div>
{/* Distribution bar */}
{total > 0 ? (
<div className="space-y-2 pt-2">
<div className="relative h-2.5 rounded-full bg-muted/40 overflow-hidden flex">
{(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => {
const count = data.distribution[bucket];
if (count === 0) return null;
const pct = (count / total) * 100;
return (
<div
key={bucket}
className={cn(HEAT_COLOR[bucket], 'h-full')}
style={{ width: `${pct}%` }}
title={`${HEAT_LABEL[bucket]}: ${count}`}
aria-hidden
/>
);
})}
</div>
<div className="flex justify-between text-[11px] text-muted-foreground">
{(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => (
<span key={bucket} className="inline-flex items-center gap-1">
<span className={cn('h-1.5 w-1.5 rounded-sm', HEAT_COLOR[bucket])} aria-hidden />
{HEAT_LABEL[bucket]} {data.distribution[bucket]}
</span>
))}
</div>
</div>
) : null}
</Card>
{/* Hottest 5 deals (lg col-span 2) */}
<Card className="p-4 lg:col-span-2 space-y-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Hottest deals right now
</p>
{data.topDeals.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No active deals yet.</p>
) : (
<ul className="divide-y divide-border">
{data.topDeals.map((deal) => (
<li key={deal.id} className="py-2 flex items-center gap-3">
<Link
href={`/${portSlug}/interests/${deal.id}` as Route}
className="text-sm font-medium text-foreground hover:text-primary transition-colors flex-1 truncate"
>
{deal.clientName}
{deal.mooringNumber ? (
<span className="text-muted-foreground font-normal">
{' '}
· {deal.mooringNumber}
</span>
) : null}
</Link>
<span
className={cn(
'text-[10px] uppercase tracking-wider font-semibold rounded px-1.5 py-0.5',
HEAT_BADGE[deal.bucket],
)}
>
{HEAT_LABEL[deal.bucket]}
</span>
<span className="text-xs text-muted-foreground">{STAGE_LABELS[deal.stage]}</span>
<span className="text-xs text-muted-foreground tabular-nums w-24 text-right">
{deal.pipelineValue > 0
? formatMoney(deal.pipelineValue, deal.pipelineValueCurrency)
: '—'}
</span>
<span className="text-xs text-muted-foreground tabular-nums w-20 text-right hidden sm:inline">
{deal.daysSinceLastContact === null
? 'never contacted'
: `${deal.daysSinceLastContact}d ago`}
</span>
</li>
))}
</ul>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,488 @@
'use client';
/**
* Sales Performance — 5 detail tables (Report 01 Tables 1-5).
*
* 1. Rep performance detail (only when single-rep ⇒ replaces
* leaderboard, which auto-hides in the parent)
* 2. Stalled deals (stage-aware thresholds)
* 3. Closing this month
* 4. Recent wins (last 5)
* 5. Lost-reason breakdown
*
* All five share the same Card primitive + table styling so they read
* as a coherent block. Rep performance detail is rendered conditionally
* by the parent (only shown for single-rep ports).
*/
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { ChevronDown, ChevronRight, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
import { useUIStore } from '@/stores/ui-store';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
// ─── Shared types (mirror service shapes) ────────────────────────────────────
interface OpenDealRow {
id: string;
clientName: string;
primaryBerth: string | null;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
lastContact: string | null;
}
interface RepPerformanceDetailRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
openDeals: OpenDealRow[];
}
interface StalledDealRow {
id: string;
clientName: string;
stage: PipelineStage;
daysSinceLastContact: number | null;
daysInStage: number | null;
stageValue: number;
stageValueCurrency: string;
rep: string;
primaryBerth: string | null;
}
interface ClosingThisMonthRow {
id: string;
clientName: string;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
rep: string;
primaryBerth: string | null;
}
interface RecentWinRow {
id: string;
clientName: string;
primaryBerth: string | null;
finalValue: number;
currency: string;
daysToClose: number | null;
rep: string;
outcomeAt: string;
}
interface LostReasonRow {
outcome: string;
count: number;
totalValueLost: number;
currency: string;
avgDaysFromFirstContactToLoss: number | null;
}
const LOSS_LABEL: Record<string, string> = {
lost_other_marina: 'Lost to competitor',
lost_unqualified: 'Unqualified',
lost_no_response: 'No response',
cancelled: 'Cancelled',
};
// ─── Public component bundles the four always-shown tables ───────────────────
interface Props {
repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[];
closingThisMonth: ClosingThisMonthRow[];
recentWins: RecentWinRow[];
lostReasonBreakdown: LostReasonRow[];
/** When false (multi-rep port), don't show Rep performance detail
* (the leaderboard above already handles that audience). */
showRepPerformanceDetail: boolean;
}
export function SalesDetailTables({
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
showRepPerformanceDetail,
}: Props) {
return (
<div className="space-y-6">
{showRepPerformanceDetail ? <RepPerformanceDetailTable rows={repPerformanceDetail} /> : null}
<StalledDealsTable rows={stalledDeals} />
<ClosingThisMonthTable rows={closingThisMonth} />
<RecentWinsTable rows={recentWins} />
<LostReasonTable rows={lostReasonBreakdown} />
</div>
);
}
// ─── 1. Rep performance detail (single-rep collapse) ─────────────────────────
function RepPerformanceDetailTable({ rows }: { rows: RepPerformanceDetailRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
// Always expand in single-rep mode: there's only one rep so collapsing
// it would be pointless. Multi-rep gets per-row toggles.
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(rows.length === 1 ? rows.map((r) => r.userId ?? 'unassigned') : []),
);
function toggle(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Rep performance detail</CardTitle>
<p className="text-xs text-muted-foreground">
Per-rep summary + their open deals. Click a row to expand the open-deals list.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No rep activity in the period.</EmptyRow>
) : (
<div className="space-y-2">
{rows.map((row) => {
const key = row.userId ?? 'unassigned';
const isOpen = expanded.has(key);
return (
<div key={key} className="rounded-md border border-border overflow-hidden">
<button
type="button"
onClick={() => toggle(key)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
) : (
<ChevronRight
className="h-4 w-4 text-muted-foreground shrink-0"
aria-hidden
/>
)}
<span className="font-medium text-foreground flex-1">{row.displayName}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{row.newDeals} new · {row.won} won · {row.lost} lost · {row.inFlight} active
</span>
</button>
{isOpen && (
<div className="border-t border-border bg-muted/20">
{row.openDeals.length === 0 ? (
<p className="px-3 py-3 text-xs text-muted-foreground">No active deals.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Client</th>
<th className="px-3 py-2 text-left font-medium">Berth</th>
<th className="px-3 py-2 text-left font-medium">Stage</th>
<th className="px-3 py-2 text-right font-medium">Value</th>
<th className="px-3 py-2 text-right font-medium">Days in stage</th>
</tr>
</thead>
<tbody>
{row.openDeals.map((d) => (
<tr key={d.id} className="border-t border-border">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${d.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{d.clientName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">
{d.primaryBerth ?? '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">
{STAGE_LABELS[d.stage]}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{d.stageValue > 0
? formatMoney(d.stageValue, row.pipelineValueCurrency)
: '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{d.daysInStage ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// ─── 2. Stalled deals ────────────────────────────────────────────────────────
function StalledDealsTable({ rows }: { rows: StalledDealRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Stalled deals</CardTitle>
<p className="text-xs text-muted-foreground">
Active deals not contacted within their stage&apos;s threshold (enquiry 21d · qualified
14d · nurturing 60d · eoi 10d · reservation 7d · deposit 7d · contract 5d).
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>Nothing stalled everything&apos;s being worked.</EmptyRow>
) : (
<TableShell
headers={['Client', 'Stage', 'Days since contact', 'Days in stage', 'Value', 'Rep']}
rightAligned={[2, 3, 4]}
>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border hover:bg-muted/40 transition-colors">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground"> · {r.primaryBerth}</span>
) : null}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{STAGE_LABELS[r.stage]}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700 font-medium">
{r.daysSinceLastContact === null ? 'never' : `${r.daysSinceLastContact}d`}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.daysInStage ?? '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">{r.rep}</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── 3. Closing this month ───────────────────────────────────────────────────
function ClosingThisMonthTable({ rows }: { rows: ClosingThisMonthRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Closing soon</CardTitle>
<p className="text-xs text-muted-foreground">
Late-stage active deals (reservation / deposit paid / contract) sorted by value. The
&quot;don&apos;t drop these&quot; list.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No deals in late stages yet.</EmptyRow>
) : (
<TableShell
headers={['Client', 'Stage', 'Days in stage', 'Value', 'Rep']}
rightAligned={[2, 3]}
>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border hover:bg-muted/40 transition-colors">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground"> · {r.primaryBerth}</span>
) : null}
</Link>
</td>
<td className="px-3 py-2">
<Badge variant="outline" className="text-[11px]">
{STAGE_LABELS[r.stage]}
</Badge>
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.daysInStage ?? '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">{r.rep}</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── 4. Recent wins ──────────────────────────────────────────────────────────
function RecentWinsTable({ rows }: { rows: RecentWinRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent wins</CardTitle>
<p className="text-xs text-muted-foreground">
The 5 most recently closed-won deals small celebratory strip.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No wins yet. The next one will appear here.</EmptyRow>
) : (
<ul className="divide-y divide-border">
{rows.map((r) => (
<li key={r.id} className="py-2.5 flex items-center gap-3">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="font-medium text-foreground hover:text-primary transition-colors flex-1 truncate"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground font-normal"> · {r.primaryBerth}</span>
) : null}
</Link>
<span className="text-sm tabular-nums text-emerald-700 font-medium w-24 text-right">
{formatMoney(r.finalValue, r.currency)}
</span>
<span className="text-xs text-muted-foreground w-24 text-right tabular-nums">
{r.daysToClose !== null ? `${r.daysToClose}d to close` : '—'}
</span>
<span className="text-xs text-muted-foreground w-28 text-right hidden sm:inline">
{r.rep}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
// ─── 5. Lost reason breakdown ────────────────────────────────────────────────
function LostReasonTable({ rows }: { rows: LostReasonRow[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Lost reason breakdown</CardTitle>
<p className="text-xs text-muted-foreground">
Where the losses went, what they cost us, and how long they took to die. Post-mortem fuel.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No losses in the period.</EmptyRow>
) : (
<TableShell
headers={['Reason', 'Count', 'Total value lost', 'Avg days to loss']}
rightAligned={[1, 2, 3]}
>
{rows.map((r) => (
<tr
key={r.outcome}
className="border-t border-border hover:bg-muted/40 transition-colors"
>
<td className="px-3 py-2 font-medium text-foreground">
{LOSS_LABEL[r.outcome] ?? r.outcome}
</td>
<td className="px-3 py-2 text-right tabular-nums">{r.count}</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.totalValueLost > 0 ? formatMoney(r.totalValueLost, r.currency) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.avgDaysFromFirstContactToLoss === null
? '—'
: `${r.avgDaysFromFirstContactToLoss.toFixed(0)}d`}
</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── Primitives ──────────────────────────────────────────────────────────────
function TableShell({
headers,
rightAligned = [],
children,
}: {
headers: string[];
rightAligned?: number[];
children: React.ReactNode;
}) {
return (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{headers.map((h, i) => (
<th
key={h}
className={cn(
'px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground',
rightAligned.includes(i) ? 'text-right' : 'text-left',
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function EmptyRow({ children }: { children: React.ReactNode }) {
return <p className="py-6 text-sm text-muted-foreground text-center">{children}</p>;
}

View File

@@ -0,0 +1,133 @@
'use client';
/**
* Sales Performance — Pipeline funnel (Report 01 Chart 1).
*
* Originally rendered as an echarts funnel, which assumes monotonically
* decreasing counts. Real pipeline data is often non-monotonic (more
* deals in Contract than in Reservation, etc.) which made the funnel
* render as a broken bowtie. Replaced with a horizontal-bar list:
* one row per canonical stage, bar length proportional to the stage's
* count relative to the max, drop-off vs the prior stage annotated on
* the right. Same data, far more honest at a glance.
*/
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface PipelineFunnelRow {
stage: PipelineStage;
count: number;
dropoffFromPrior: number | null;
}
interface Props {
rows: PipelineFunnelRow[];
}
// Brand palette graded across the 7 stages - earlier stages lighter
// (top of funnel = wide / lower-intent), later stages darker brand-blue
// (bottom of funnel = narrow / high-intent). Matches the existing
// STAGE_DOT palette in spirit while sticking to the brand ramp.
const STAGE_BAR_COLOR: Record<PipelineStage, string> = {
enquiry: 'bg-slate-400',
qualified: 'bg-brand-300',
nurturing: 'bg-brand-300/70',
eoi: 'bg-brand-400',
reservation: 'bg-brand-500',
deposit_paid: 'bg-brand-600',
contract: 'bg-brand-700',
};
export function SalesPipelineFunnel({ rows }: Props) {
const max = Math.max(1, ...rows.map((r) => r.count));
return (
<div className="space-y-2.5">
{rows.map((row) => {
const widthPct = (row.count / max) * 100;
const isZero = row.count === 0;
return (
<div
key={row.stage}
className="grid items-center gap-3"
// Inline style guarantees the 3-column track (label | bar |
// drop-off badge) on Tailwind v4 - the arbitrary
// `grid-cols-[...]` utility's underscore-to-space
// conversion was silently dropping the class in some
// builds, collapsing the row to stacked.
style={{ gridTemplateColumns: '140px 1fr 120px' }}
>
{/* Stage label */}
<div className="text-sm font-medium text-foreground tabular-nums">
{STAGE_LABELS[row.stage]}
</div>
{/* Bar */}
<div className="relative h-6 rounded-sm bg-muted/40 overflow-hidden">
<div
className={cn(
'h-full rounded-sm transition-[width] duration-500 ease-out',
STAGE_BAR_COLOR[row.stage],
isZero && 'opacity-30',
)}
style={{ width: `${Math.max(widthPct, isZero ? 0 : 1.5)}%` }}
aria-hidden
/>
<div
className={cn(
'absolute inset-y-0 left-2 flex items-center text-xs font-semibold tabular-nums',
// Place the count inside the bar when there's room, else outside (right of bar)
widthPct > 15 ? 'text-white' : 'text-foreground',
)}
style={widthPct > 15 ? undefined : { left: `calc(${widthPct}% + 8px)` }}
>
{row.count}
</div>
</div>
{/* Drop-off vs prior */}
<DropoffBadge dropoff={row.dropoffFromPrior} />
</div>
);
})}
</div>
);
}
function DropoffBadge({ dropoff }: { dropoff: number | null }) {
if (dropoff === null) {
return <span className="text-[11px] text-muted-foreground"></span>;
}
const pct = Math.round(dropoff * 100);
if (pct === 0) {
return (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Minus className="h-3 w-3" aria-hidden />
no change
</span>
);
}
// Negative drop-off (the typical case in a funnel) is shown in slate
// not red - it's normal for stages to shrink. Positive drop-off
// (rare; means more in this stage than the prior) gets emerald.
const isPositive = pct > 0;
return (
<span
className={cn(
'inline-flex items-center gap-1 text-[11px] font-medium tabular-nums',
isPositive ? 'text-emerald-700' : 'text-muted-foreground',
)}
>
{isPositive ? (
<ArrowUpRight className="h-3 w-3" aria-hidden />
) : (
<ArrowDownRight className="h-3 w-3" aria-hidden />
)}
{isPositive ? '+' : ''}
{pct}%
</span>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
/**
* Sales Performance — Rep leaderboard (Report 01 Chart 5).
*
* Table with per-rep summary stats. Single-rep collapse: when there's
* only one rep with deals, the parent component renders the Rep
* performance detail block instead (Task #32). This component
* itself only renders the leaderboard view.
*
* Pipeline-value column carries a small bar fill so the visual
* comparison is fast — bigger bar = more $$ in their pipeline. Other
* columns are pure numerics with tabular-nums alignment.
*/
import { cn } from '@/lib/utils';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
interface RepLeaderboardRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
}
interface Props {
rows: RepLeaderboardRow[];
}
export function SalesRepLeaderboard({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No rep activity in the selected period.
</p>
</div>
);
}
const maxPipeline = Math.max(1, ...rows.map((r) => r.pipelineValue));
return (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-2 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Rep
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
New
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Won
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Lost
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
In flight
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground min-w-[160px]">
Pipeline value
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Win rate
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Median close
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const pct = (row.pipelineValue / maxPipeline) * 100;
return (
<tr
key={row.userId ?? 'unassigned'}
className="border-b border-border last:border-b-0 hover:bg-muted/40 transition-colors"
>
<td className="px-2 py-2.5 font-medium text-foreground">{row.displayName}</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.newDeals}
</td>
<td
className={cn(
'px-2 py-2.5 text-right tabular-nums',
row.won > 0 ? 'text-emerald-700 font-medium' : 'text-foreground',
)}
>
{row.won}
</td>
<td
className={cn(
'px-2 py-2.5 text-right tabular-nums',
row.lost > 0 ? 'text-rose-700' : 'text-muted-foreground',
)}
>
{row.lost}
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.inFlight}
</td>
<td className="px-2 py-2.5">
<div className="flex items-center justify-end gap-2">
<div className="relative h-2 w-20 rounded-full bg-muted/60 overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-brand-500 rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${Math.max(pct, row.pipelineValue > 0 ? 4 : 0)}%` }}
aria-hidden
/>
</div>
<span className="tabular-nums text-foreground min-w-[90px] text-right">
{formatMoney(row.pipelineValue, row.pipelineValueCurrency)}
</span>
</div>
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.winRate === null ? '—' : `${(row.winRate * 100).toFixed(0)}%`}
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-muted-foreground">
{row.medianTimeToCloseDays === null
? '—'
: `${row.medianTimeToCloseDays.toFixed(0)}d`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,846 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { TrendingDown, TrendingUp } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
import { ReportExportButton } from '@/components/reports/shared/report-export-button';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import {
FilterBar,
type FilterDefinition,
type FilterValues,
} from '@/components/shared/filter-bar';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
import { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types';
import { SalesPipelineFunnel } from './sales-pipeline-funnel';
import { SalesStageVelocity } from './sales-stage-velocity';
import { SalesWinRateOverTime } from './sales-win-rate-over-time';
import { SalesSourceConversion } from './sales-source-conversion';
import { SalesRepLeaderboard } from './sales-rep-leaderboard';
import { SalesDealHeat } from './sales-deal-heat';
import { SalesDetailTables } from './sales-detail-tables';
interface SalesKpis {
activeInterests: number;
wonInWindow: number;
lostInWindow: number;
lossBreakdown: Array<{ outcome: string; count: number }>;
winRate: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
pipelineValueExcludedCount: number;
pipelineValueTotalActiveCount: number;
medianTimeToCloseDays: number | null;
timeToCloseSampleSize: number;
newLeadsInWindow: number;
newLeadsBySource: Array<{ source: string; count: number }>;
}
interface FunnelRow {
stage: PipelineStage;
count: number;
dropoffFromPrior: number | null;
}
interface StageVelocityRow {
stage: PipelineStage;
medianDays: number | null;
p90Days: number | null;
transitions: number;
}
interface WinRatePoint {
bucket: string;
won: number;
lost: number;
winRate: number | null;
}
interface WinRateOverTime {
granularity: 'week' | 'month' | 'quarter';
points: WinRatePoint[];
}
type SourceOutcome = 'won' | 'lost' | 'cancelled' | 'in_flight';
interface SourceConversionRow {
source: string;
counts: Record<SourceOutcome, number>;
total: number;
}
interface RepLeaderboardRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
}
type HeatBucket = 'hot' | 'warm' | 'cold';
interface DealHeatSummary {
distribution: Record<HeatBucket, number>;
topDeals: Array<{
id: string;
clientName: string;
mooringNumber: string | null;
stage: PipelineStage;
bucket: HeatBucket;
daysSinceLastContact: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
}>;
}
interface OpenDealRow {
id: string;
clientName: string;
primaryBerth: string | null;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
lastContact: string | null;
}
interface RepPerformanceDetailRow extends RepLeaderboardRow {
openDeals: OpenDealRow[];
}
interface StalledDealRow {
id: string;
clientName: string;
stage: PipelineStage;
daysSinceLastContact: number | null;
daysInStage: number | null;
stageValue: number;
stageValueCurrency: string;
rep: string;
primaryBerth: string | null;
}
interface ClosingThisMonthRow {
id: string;
clientName: string;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
rep: string;
primaryBerth: string | null;
}
interface RecentWinRow {
id: string;
clientName: string;
primaryBerth: string | null;
finalValue: number;
currency: string;
daysToClose: number | null;
rep: string;
outcomeAt: string;
}
interface LostReasonRow {
outcome: string;
count: number;
totalValueLost: number;
currency: string;
avgDaysFromFirstContactToLoss: number | null;
}
interface SalesReportPayload {
data: {
kpis: SalesKpis;
funnel: FunnelRow[];
stageVelocity: StageVelocityRow[];
winRateOverTime: WinRateOverTime;
sourceConversion: SourceConversionRow[];
repLeaderboard: RepLeaderboardRow[];
dealHeat: DealHeatSummary;
repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[];
closingThisMonth: ClosingThisMonthRow[];
recentWins: RecentWinRow[];
lostReasonBreakdown: LostReasonRow[];
range: { from: string; to: string };
};
}
const LOSS_LABELS: Record<string, string> = {
lost_other_marina: 'to competitor',
lost_unqualified: 'unqualified',
lost_no_response: 'no response',
cancelled: 'cancelled',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'website',
referral: 'referral',
broker: 'broker',
manual: 'manual',
unknown: 'unknown',
};
const FILTER_DEFS: FilterDefinition[] = [
{
key: 'stage',
label: 'Stage',
type: 'multi-select',
options: PIPELINE_STAGES.map((s) => ({ value: s, label: STAGE_LABELS[s] })),
},
{
key: 'leadCategory',
label: 'Lead category',
type: 'multi-select',
options: [
{ value: 'hot_lead', label: 'Hot lead' },
{ value: 'specific_qualified', label: 'Specific qualified' },
{ value: 'general_interest', label: 'General interest' },
],
},
{
key: 'outcome',
label: 'Outcome',
type: 'multi-select',
options: Object.entries(OUTCOME_LABELS).map(([value, label]) => ({ value, label })),
},
];
interface SalesTemplateConfig extends Record<string, unknown> {
kind: 'sales';
range: DateRange;
filters: FilterValues;
}
export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
const [range, setRange] = useState<DateRange>('30d');
const [filterValues, setFilterValues] = useState<FilterValues>({});
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
// Wrap the user-driven setters so any view-state change clears the
// "Using template X" badge. Template-apply goes through the raw
// setters via handleApplyTemplate, so loading a template doesn't
// immediately clear its own badge.
const handleRangeChange = useCallback((next: DateRange) => {
setRange(next);
setActiveTemplateId(null);
}, []);
const handleFilterChange = useCallback((key: string, value: unknown) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setActiveTemplateId(null);
}, []);
const handleFiltersClear = useCallback(() => {
setFilterValues({});
setActiveTemplateId(null);
}, []);
const currentConfig: SalesTemplateConfig = useMemo(
() => ({ kind: 'sales', range, filters: filterValues }),
[range, filterValues],
);
const handleApplyTemplate = useCallback((config: SalesTemplateConfig) => {
// Raw setters here: applying a template MUST NOT clear the
// active-template badge, which the user-facing setters above do.
if (config.range) setRange(config.range);
setFilterValues(config.filters ?? {});
}, []);
const bounds = useMemo(() => rangeToBounds(range), [range]);
const filterQs = useMemo(() => {
const parts: string[] = [];
for (const def of FILTER_DEFS) {
const v = filterValues[def.key];
if (Array.isArray(v) && v.length > 0) {
parts.push(`${def.key}=${encodeURIComponent(v.join(','))}`);
}
}
return parts.length > 0 ? `&${parts.join('&')}` : '';
}, [filterValues]);
const query = useQuery<SalesReportPayload>({
queryKey: ['reports', 'sales', bounds.from.toISOString(), bounds.to.toISOString(), filterQs],
queryFn: () =>
apiFetch<SalesReportPayload>(
`/api/v1/reports/sales?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`,
),
staleTime: 30_000,
});
const kpis = query.data?.data.kpis;
const funnel = query.data?.data.funnel ?? [];
const stageVelocity = query.data?.data.stageVelocity ?? [];
const winRateOverTime = query.data?.data.winRateOverTime ?? {
granularity: 'week' as const,
points: [],
};
const sourceConversion = query.data?.data.sourceConversion ?? [];
const repLeaderboard = query.data?.data.repLeaderboard ?? [];
// Locked decision: when only ONE rep has activity in window, the
// leaderboard table is awkward (1-row scoreboard). Hide it; the Rep
// performance detail (Task #32) will pick up the slack.
const showLeaderboard = repLeaderboard.length > 1;
const dealHeat = query.data?.data.dealHeat;
const repPerformanceDetail = query.data?.data.repPerformanceDetail ?? [];
const stalledDeals = query.data?.data.stalledDeals ?? [];
const closingThisMonth = query.data?.data.closingThisMonth ?? [];
const recentWins = query.data?.data.recentWins ?? [];
const lostReasonBreakdown = query.data?.data.lostReasonBreakdown ?? [];
/**
* Build the export payload at click time. Closed over the current
* `kpis` / `funnel` / `bounds` so the user gets the report they're
* looking at, not whatever the page state was at first render.
*/
function buildExportPayload(): ReportPayload {
if (!kpis) {
throw new Error('Report still loading');
}
// Every money figure in the payload is already in the port's
// reporting currency (service converts on read). Money rows below
// are pre-formatted into strings so the export-pdf route (which
// strips column.format callbacks at the JSON boundary) and the
// CSV / XLSX exporters (which keep them) all render the same
// currency-formatted text.
return {
title: 'Sales performance',
description: 'Rep performance, win rates, pipeline value, stalled deals, deal heat.',
filenameSlug: 'sales-performance',
range: bounds,
kpis: [
{ label: 'Active interests', value: kpis.activeInterests },
{ label: 'Won in period', value: kpis.wonInWindow },
{
label: 'Lost in period',
value: kpis.lostInWindow,
hint: kpis.lossBreakdown
.map((b) => `${b.count} ${b.outcome.replace(/^lost_/, '')}`)
.join(', '),
},
{
label: 'Win rate',
value: kpis.winRate === null ? '—' : `${(kpis.winRate * 100).toFixed(1)}%`,
},
{
label: 'Pipeline value',
value: formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency),
hint: `${kpis.pipelineValueTotalActiveCount} active interests`,
},
{
label: 'Avg time to close',
value:
kpis.medianTimeToCloseDays === null
? '—'
: `${kpis.medianTimeToCloseDays.toFixed(1)} days`,
hint:
kpis.medianTimeToCloseDays !== null
? `based on ${kpis.timeToCloseSampleSize} won deals`
: 'need ≥3 won deals',
},
{
label: 'New leads',
value: kpis.newLeadsInWindow,
hint: kpis.newLeadsBySource.map((s) => `${s.count} ${s.source}`).join(', '),
},
],
sections: [
{
title: 'Pipeline funnel',
columns: [
{ key: 'stage', label: 'Stage' },
{ key: 'count', label: 'Active deals', align: 'right' },
{
key: 'dropoffFromPrior',
label: 'Drop-off vs prior',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: funnel.map((r) => ({
stage: STAGE_LABELS[r.stage],
count: r.count,
dropoffFromPrior: r.dropoffFromPrior,
})),
},
{
title: 'Stage velocity',
columns: [
{ key: 'stage', label: 'Stage' },
{
key: 'medianDays',
label: 'Median days in stage',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{
key: 'p90Days',
label: 'p90 days',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{ key: 'transitions', label: 'Sample size', align: 'right' },
],
rows: stageVelocity.map((r) => ({
stage: STAGE_LABELS[r.stage],
medianDays: r.medianDays,
p90Days: r.p90Days,
transitions: r.transitions,
})),
},
{
title: `Win rate over time (${winRateOverTime.granularity})`,
columns: [
{ key: 'bucket', label: 'Period' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '—' : `${((v as number) * 100).toFixed(1)}%`,
},
],
rows: winRateOverTime.points.map((p) => ({ ...p })),
},
{
title: 'Source → win conversion',
columns: [
{ key: 'source', label: 'Source' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'cancelled', label: 'Cancelled', align: 'right' },
{ key: 'in_flight', label: 'In flight', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: sourceConversion.map((r) => ({
source: r.source,
won: r.counts.won,
lost: r.counts.lost,
cancelled: r.counts.cancelled,
in_flight: r.counts.in_flight,
total: r.total,
})),
},
{
title: 'Rep leaderboard',
columns: [
{ key: 'displayName', label: 'Rep' },
{ key: 'newDeals', label: 'New', align: 'right' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'inFlight', label: 'In flight', align: 'right' },
{
key: 'pipelineValue',
label: 'Pipeline value',
align: 'right',
},
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
{
key: 'medianTimeToCloseDays',
label: 'Median close (days)',
align: 'right',
format: (v) => (v === null || v === undefined ? '' : (v as number).toFixed(1)),
},
],
// Pre-format `pipelineValue` per row so PDF (which strips the
// column.format callback at the server boundary) and CSV / XLSX
// (which keep it) all render the same currency-formatted
// string.
rows: repLeaderboard.map((r) => ({
...r,
pipelineValue: formatMoney(r.pipelineValue, r.pipelineValueCurrency),
})),
},
...(dealHeat
? [
{
title: 'Deal heat — hottest deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'mooringNumber', label: 'Berth' },
{
key: 'stage',
label: 'Stage',
format: (v: unknown) => STAGE_LABELS[v as PipelineStage] ?? '',
},
{ key: 'bucket', label: 'Heat' },
{
key: 'daysSinceLastContact',
label: 'Days since contact',
align: 'right' as const,
format: (v: unknown) => (v === null || v === undefined ? 'never' : String(v)),
},
{
key: 'pipelineValue',
label: 'Value',
align: 'right' as const,
},
],
// Same pre-format treatment as the leaderboard above —
// closure-format here so the PDF render path sees a
// ready-to-print string.
rows: dealHeat.topDeals.map((d) => ({
...d,
pipelineValue: formatMoney(d.pipelineValue, d.pipelineValueCurrency),
})),
},
]
: []),
],
};
}
return (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Sales performance"
description="Rep performance, win rates, pipeline value, stalled deals, and deal heat."
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={handleRangeChange} />
<ReportTemplatesButton<SalesTemplateConfig>
kind="sales"
currentConfig={currentConfig}
onApply={handleApplyTemplate}
activeTemplateId={activeTemplateId}
onActiveTemplateChange={setActiveTemplateId}
initialTemplateId={initialTemplateId}
/>
<ReportExportButton buildPayload={buildExportPayload} disabled={!kpis} />
</div>
}
/>
{/* KPI STRIP - 7 tiles. Grid scales from 2-up on mobile to 4-up
on lg; the 7th tile wraps naturally to a second row. */}
<section
aria-label="Sales KPIs"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
>
{query.isLoading || !kpis ? (
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard
label="Active interests"
value={formatInt(kpis.activeInterests)}
hint="Not archived, no outcome set"
/>
<KpiCard
label="Won in period"
value={formatInt(kpis.wonInWindow)}
valueTrend={kpis.wonInWindow > 0 ? 'positive' : 'neutral'}
/>
<KpiCard
label="Lost in period"
value={formatInt(kpis.lostInWindow)}
valueTrend={kpis.lostInWindow > 0 ? 'negative' : 'neutral'}
hint={
kpis.lossBreakdown.length > 0
? kpis.lossBreakdown
.map((b) => `${b.count} ${LOSS_LABELS[b.outcome] ?? b.outcome}`)
.join(' · ')
: undefined
}
/>
<KpiCard
label="Win rate"
value={kpis.winRate === null ? '—' : formatPercent(kpis.winRate)}
hint={kpis.winRate === null ? 'No closed deals in period' : 'Excludes cancellations'}
/>
<KpiCard
label="Pipeline value"
value={formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency)}
hint={
kpis.pipelineValueExcludedCount > 0
? `${kpis.pipelineValueExcludedCount} of ${kpis.pipelineValueTotalActiveCount} interests have no value`
: `${kpis.pipelineValueTotalActiveCount} active interests · weighted by stage`
}
/>
<KpiCard
label="Avg time to close"
value={
kpis.medianTimeToCloseDays === null
? '—'
: formatDurationFromDays(kpis.medianTimeToCloseDays)
}
hint={
kpis.medianTimeToCloseDays === null
? 'Need ≥3 won deals for a meaningful median'
: `Based on ${kpis.timeToCloseSampleSize} won deals`
}
/>
<KpiCard
label="New leads"
value={formatInt(kpis.newLeadsInWindow)}
hint={
kpis.newLeadsBySource.length > 0
? kpis.newLeadsBySource
.map((s) => `${s.count} ${SOURCE_LABELS[s.source] ?? s.source}`)
.join(' · ')
: undefined
}
/>
</>
)}
</section>
{/* CHART 1 - Pipeline funnel */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pipeline funnel</CardTitle>
<p className="text-xs text-muted-foreground">
Active interests grouped by stage. Drop-off rate shown between consecutive stages.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[360px] w-full" />
) : funnel.every((r) => r.count === 0) ? (
<EmptyState>
No active interests yet. New deals appear here as they enter the pipeline.
</EmptyState>
) : (
<SalesPipelineFunnel rows={funnel} />
)}
</CardContent>
</Card>
{/* CHART 2 - Stage velocity */}
<Card>
<CardHeader>
<CardTitle className="text-base">Stage velocity</CardTitle>
<p className="text-xs text-muted-foreground">
Median days deals spend in each stage before moving on, with the p90 marker on each bar.
Derived from the stage-change audit log.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<SalesStageVelocity rows={stageVelocity} />
)}
</CardContent>
</Card>
{/* CHART 3 - Win rate over time */}
<Card>
<CardHeader>
<CardTitle className="text-base">Win rate over time</CardTitle>
<p className="text-xs text-muted-foreground">
Win rate per {winRateOverTime.granularity}. Faint area underlay is the total deals
closed in each bucket so 100% on 1 deal doesn&apos;t read as 100% on 50.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<SalesWinRateOverTime
granularity={winRateOverTime.granularity}
points={winRateOverTime.points}
/>
)}
</CardContent>
</Card>
{/* CHART 4 - Source → win conversion */}
<Card>
<CardHeader>
<CardTitle className="text-base">Source win conversion</CardTitle>
<p className="text-xs text-muted-foreground">
For each lead source, the share of deals that ended up won, lost, cancelled, or are
still in flight. PDF-friendly stacked bars (not sankey).
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[200px] w-full" />
) : (
<SalesSourceConversion rows={sourceConversion} />
)}
</CardContent>
</Card>
{/* CHART 5 - Rep leaderboard (auto-hidden when only one rep
has activity; the Rep performance detail block ships as
Task #32 and will fill that slot). */}
{showLeaderboard ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Rep leaderboard</CardTitle>
<p className="text-xs text-muted-foreground">
Per-rep activity in the period. Pipeline value is the rep&apos;s slice of the
port-wide stage-weighted forecast, normalised to port currency.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[200px] w-full" />
) : (
<SalesRepLeaderboard rows={repLeaderboard} />
)}
</CardContent>
</Card>
) : null}
{/* DEAL HEAT SECTION - sits between leaderboard + detail tables
per the locked spec. Hot deals count + heat distribution +
hottest 5 deals (linkable). */}
{query.isLoading || !dealHeat ? (
<Skeleton className="h-[180px] w-full" />
) : (
<SalesDealHeat data={dealHeat} />
)}
{/* DETAIL-TABLE FILTERS — narrow the next 5 tables by stage / lead
category / outcome. KPIs + charts above intentionally stay
unfiltered (macro view). */}
<div className="flex items-center justify-between gap-2 pt-2">
<h2 className="text-sm font-semibold text-foreground">Deal detail</h2>
<FilterBar
filters={FILTER_DEFS}
values={filterValues}
onChange={handleFilterChange}
onClear={handleFiltersClear}
/>
</div>
{/* 5 DETAIL TABLES - Rep performance detail (single-rep only) /
Stalled deals / Closing soon / Recent wins / Lost-reason
breakdown. */}
{query.isLoading ? (
<Skeleton className="h-[400px] w-full" />
) : (
<SalesDetailTables
repPerformanceDetail={repPerformanceDetail}
stalledDeals={stalledDeals}
closingThisMonth={closingThisMonth}
recentWins={recentWins}
lostReasonBreakdown={lostReasonBreakdown}
showRepPerformanceDetail={!showLeaderboard}
/>
)}
</div>
);
}
// ─── KPI tile primitives ─────────────────────────────────────────────────────
interface KpiCardProps {
label: string;
value: string;
hint?: string;
valueTrend?: 'positive' | 'negative' | 'neutral';
}
function KpiCard({ label, value, hint, valueTrend = 'neutral' }: KpiCardProps) {
// Padding goes directly on the bare Card (skipping CardContent)
// because CardContent ships with `p-4 pt-0 sm:p-6 sm:pt-0` for
// use-with-CardHeader contexts. KPI tiles have no header, so any
// `pt-*` override gets stripped or stomped by tailwind-merge +
// breakpoint specificity. Cleaner to skip CardContent entirely.
return (
<Card className="h-full p-4 space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<div className="flex items-baseline gap-1.5">
<p className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
{value}
</p>
{valueTrend === 'positive' ? (
<TrendingUp className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
) : valueTrend === 'negative' ? (
<TrendingDown className="h-3.5 w-3.5 text-rose-600" aria-hidden />
) : null}
</div>
{hint ? (
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
) : null}
</Card>
);
}
function KpiSkeleton() {
return (
<Card className="h-full p-4 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-16" />
<Skeleton className="h-3 w-32" />
</Card>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="py-16 flex flex-col items-center justify-center text-center space-y-2">
<Badge variant="outline" className="text-muted-foreground">
No data
</Badge>
<p className="text-sm text-muted-foreground max-w-xs">{children}</p>
</div>
);
}
// ─── Formatting helpers ──────────────────────────────────────────────────────
function formatInt(n: number): string {
return new Intl.NumberFormat(undefined).format(n);
}
function formatPercent(fraction: number): string {
return `${Math.round(fraction * 1000) / 10}%`;
}
// Money helpers come from the shared module — `formatMoney` for KPI
// tile readability, `formatMoneyCompact` for tight dense tables.
/**
* Adaptive duration string per locked decision: days under 60, weeks
* under 24 weeks, otherwise months. Single-decimal rounding keeps the
* tile compact.
*/
function formatDurationFromDays(days: number): string {
if (days < 60) return `${Math.round(days)}d`;
const weeks = days / 7;
if (weeks < 24) return `${Math.round(weeks)}w`;
const months = days / 30.44;
return `${months.toFixed(1)}mo`;
}
// Reference the stage labels import so it stays load-bearing across
// later phases (used by the funnel + leaderboard component imports).
void STAGE_LABELS;

View File

@@ -0,0 +1,126 @@
'use client';
/**
* Sales Performance — Source → win conversion (Report 01 Chart 4).
*
* Stacked horizontal bar per lead source, segments coloured by
* outcome (won / lost / cancelled / in-flight). PDF-safe (we picked
* stacked-bar over sankey for that exact reason — locked decision).
*
* Each bar normalises to 100% width so source-to-source comparison
* shows MIX of outcomes regardless of absolute volume. Bar's total
* count is shown on the right so a 50% win rate on 2 deals doesn't
* read the same as 50% on 50.
*/
import { cn } from '@/lib/utils';
type Outcome = 'won' | 'lost' | 'cancelled' | 'in_flight';
interface SourceConversionRow {
source: string;
counts: Record<Outcome, number>;
total: number;
}
interface Props {
rows: SourceConversionRow[];
}
const SOURCE_LABEL: Record<string, string> = {
website: 'Website',
referral: 'Referral',
broker: 'Broker',
manual: 'Manual',
unknown: 'Unknown',
};
const OUTCOME_LABEL: Record<Outcome, string> = {
won: 'Won',
lost: 'Lost',
cancelled: 'Cancelled',
in_flight: 'In flight',
};
// Reuse brand palette. Won = brand-blue (primary success in this app's
// language); Lost = warm rose; Cancelled = muted slate; In-flight =
// soft sage tint so it reads as "still moving" without competing.
const OUTCOME_COLOR: Record<Outcome, string> = {
won: 'bg-brand-600',
lost: 'bg-rose-500',
cancelled: 'bg-slate-400',
in_flight: 'bg-amber-400',
};
export function SalesSourceConversion({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No leads yet. Source-to-win attribution appears as deals start landing in the pipeline.
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* Legend */}
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
{(Object.keys(OUTCOME_LABEL) as Outcome[]).map((o) => (
<span key={o} className="inline-flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-sm', OUTCOME_COLOR[o])} aria-hidden />
{OUTCOME_LABEL[o]}
</span>
))}
</div>
{/* Rows */}
<div className="space-y-2.5">
{rows.map((row) => (
<div
key={row.source}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '120px 1fr 70px' }}
>
<div className="text-sm font-medium text-foreground">
{SOURCE_LABEL[row.source] ?? row.source}
</div>
<div
className="relative h-6 rounded-sm bg-muted/40 overflow-hidden flex"
role="img"
aria-label={`${row.source}: ${describeRow(row)}`}
>
{(Object.keys(OUTCOME_COLOR) as Outcome[]).map((outcome) => {
const count = row.counts[outcome];
if (count === 0) return null;
const pct = (count / row.total) * 100;
return (
<div
key={outcome}
className={cn(OUTCOME_COLOR[outcome], 'h-full')}
style={{ width: `${pct}%` }}
title={`${OUTCOME_LABEL[outcome]}: ${count} (${pct.toFixed(0)}%)`}
aria-hidden
/>
);
})}
</div>
<span className="text-[11px] text-muted-foreground tabular-nums text-right">
{row.total} {row.total === 1 ? 'lead' : 'leads'}
</span>
</div>
))}
</div>
</div>
);
}
function describeRow(row: SourceConversionRow): string {
return (Object.keys(OUTCOME_LABEL) as Outcome[])
.filter((o) => row.counts[o] > 0)
.map((o) => `${row.counts[o]} ${OUTCOME_LABEL[o].toLowerCase()}`)
.join(', ');
}

View File

@@ -0,0 +1,132 @@
'use client';
/**
* Sales Performance — Stage velocity (Report 01 Chart 2).
*
* Median days spent in each pipeline stage with a faint p90 marker.
* Same horizontal-bar pattern as the Pipeline funnel so the two charts
* read as a pair on the page.
*/
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface StageVelocityRow {
stage: PipelineStage;
medianDays: number | null;
p90Days: number | null;
transitions: number;
}
interface Props {
rows: StageVelocityRow[];
}
const STAGE_BAR_COLOR: Record<PipelineStage, string> = {
enquiry: 'bg-slate-400',
qualified: 'bg-brand-300',
nurturing: 'bg-brand-300/70',
eoi: 'bg-brand-400',
reservation: 'bg-brand-500',
deposit_paid: 'bg-brand-600',
contract: 'bg-brand-700',
};
export function SalesStageVelocity({ rows }: Props) {
const hasData = rows.some((r) => r.medianDays !== null);
if (!hasData) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No stage transitions captured yet. Velocity appears here once deals start moving between
stages.
</p>
</div>
);
}
// Scale all bars + p90 markers against the highest p90 we observed so
// a tail outlier doesn't crush the rest of the bars to nothing.
const max = Math.max(1, ...rows.map((r) => r.p90Days ?? r.medianDays ?? 0));
return (
<div className="space-y-2.5">
{rows.map((row) => {
const median = row.medianDays;
const p90 = row.p90Days;
const medianPct = median !== null ? (median / max) * 100 : 0;
const p90Pct = p90 !== null ? (p90 / max) * 100 : 0;
const isMissing = median === null;
return (
<div
key={row.stage}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '140px 1fr 120px' }}
>
<div className="text-sm font-medium text-foreground">{STAGE_LABELS[row.stage]}</div>
<div className="relative h-6 rounded-sm bg-muted/40 overflow-hidden">
{/* Median bar */}
{!isMissing && (
<div
className={cn(
'h-full rounded-sm transition-[width] duration-500 ease-out',
STAGE_BAR_COLOR[row.stage],
)}
style={{ width: `${Math.max(medianPct, 1.5)}%` }}
aria-hidden
/>
)}
{/* p90 marker (vertical line) */}
{p90 !== null && p90 > 0 && p90Pct > 0 && (
<div
className="absolute top-0 bottom-0 w-px bg-foreground/60"
style={{ left: `calc(${p90Pct}% - 0.5px)` }}
title={`p90: ${formatDays(p90)}`}
aria-hidden
/>
)}
{/* Label inside or outside the bar */}
<div
className={cn(
'absolute inset-y-0 left-2 flex items-center text-xs font-semibold tabular-nums',
isMissing
? 'text-muted-foreground'
: medianPct > 18
? 'text-white'
: 'text-foreground',
)}
style={
isMissing || medianPct > 18 ? undefined : { left: `calc(${medianPct}% + 8px)` }
}
>
{isMissing ? '—' : formatDays(median!)}
</div>
</div>
{/* Sample size + p90 chip on the right */}
<span className="text-[11px] text-muted-foreground tabular-nums">
{isMissing ? (
'no data'
) : (
<>
{row.transitions} {row.transitions === 1 ? 'transition' : 'transitions'}
{p90 !== null && p90 > 0 ? ` · p90 ${formatDays(p90)}` : ''}
</>
)}
</span>
</div>
);
})}
</div>
);
}
function formatDays(days: number): string {
if (days < 1) return `<1d`;
if (days < 10) return `${days.toFixed(1)}d`;
if (days < 60) return `${Math.round(days)}d`;
const weeks = days / 7;
if (weeks < 24) return `${Math.round(weeks)}w`;
return `${(days / 30.44).toFixed(1)}mo`;
}

View File

@@ -0,0 +1,145 @@
'use client';
/**
* Sales Performance — Win rate over time (Report 01 Chart 3).
*
* Line: win rate per bucket. Faint area underlay: total deals closed
* per bucket so a 100% win rate on 1 deal doesn't read the same as
* 80% on 50 deals. Auto-bucket granularity (weekly / monthly /
* quarterly) is decided server-side and labelled in the chart caption.
*
* Recharts (matches the dashboard convention).
*/
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type Granularity = 'week' | 'month' | 'quarter';
interface WinRatePoint {
bucket: string;
won: number;
lost: number;
winRate: number | null;
}
interface Props {
granularity: Granularity;
points: WinRatePoint[];
}
export function SalesWinRateOverTime({ granularity, points }: Props) {
const allEmpty = points.every((p) => p.winRate === null);
if (allEmpty) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No deals closed yet in the selected period. Win-rate trend appears here as wins and losses
accumulate.
</p>
</div>
);
}
// Build the chart series. Render win rate as a percentage so the
// tooltip + axis read naturally; preserve the null gaps by passing
// `null` for winRatePct on empty buckets (recharts skips them).
const data = points.map((p) => ({
bucket: formatBucket(p.bucket, granularity),
winRatePct: p.winRate === null ? null : Math.round(p.winRate * 100 * 10) / 10,
closed: p.won + p.lost,
}));
// p90 for the volume underlay scale - we want the area to feel like
// ambient context, not dominate. Capping at p90 trims spike weeks.
const closedSorted = data.map((d) => d.closed).sort((a, b) => a - b);
const p90Closed = closedSorted[Math.floor(closedSorted.length * 0.9)] ?? 1;
const maxClosed = Math.max(p90Closed, 1);
return (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={data} margin={{ top: 8, right: 8, left: -16, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
{/* Left axis: win rate %, fixed 0-100 scale so deltas read true */}
<YAxis
yAxisId="rate"
domain={[0, 100]}
tickFormatter={(v) => `${v}%`}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/>
{/* Right axis: deals closed (volume underlay). Hidden but used
so the area can scale independently of the line. */}
<YAxis yAxisId="volume" orientation="right" domain={[0, maxClosed * 1.2]} hide />
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, name) => {
if (name === 'winRatePct') {
return [value === null ? '—' : `${value}%`, 'Win rate'];
}
if (name === 'closed') {
return [value, 'Deals closed'];
}
return [value, String(name)];
}}
/>
<Area
yAxisId="volume"
type="monotone"
dataKey="closed"
stroke="none"
fill="hsl(var(--muted))"
fillOpacity={0.55}
isAnimationActive={false}
/>
<Line
yAxisId="rate"
type="monotone"
dataKey="winRatePct"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ r: 3, fill: 'hsl(var(--primary))' }}
activeDot={{ r: 5 }}
// Recharts renders gaps where the value is null.
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
function formatBucket(bucket: string, granularity: Granularity): string {
if (granularity === 'week') {
// "2026-W18" → "W18"
const m = bucket.match(/-W(\d+)/);
return m ? `W${m[1]}` : bucket;
}
if (granularity === 'month') {
// "2026-04" → "Apr"
const [year, month] = bucket.split('-');
if (!year || !month) return bucket;
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return date.toLocaleDateString(undefined, { month: 'short' });
}
// "2026-Q2" → "Q2 '26"
const m = bucket.match(/(\d{4})-Q(\d)/);
if (!m) return bucket;
return `Q${m[2]} '${m[1]!.slice(-2)}`;
}

View File

@@ -30,7 +30,7 @@ export interface SavedTemplate {
}
interface Props {
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
kind: 'dashboard' | 'clients' | 'berths' | 'interests' | 'sales' | 'operational';
/** Called when the rep picks a template from the dropdown - the
* parent hydrates its form from the returned config. */
onApply: (template: SavedTemplate) => void;

View File

@@ -0,0 +1,357 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Save, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { ReportSchedule, ReportTemplate } from '@/lib/db/schema/reports';
type Cadence = 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9';
type OutputFormat = 'pdf' | 'csv' | 'png';
const CADENCE_OPTIONS: ReadonlyArray<{ value: Cadence; label: string }> = [
{ value: 'weekly_monday_9', label: 'Weekly · Monday 9:00 UTC' },
{ value: 'monthly_first_9', label: 'Monthly · 1st of month 9:00 UTC' },
{ value: 'quarterly_first_9', label: 'Quarterly · 1st of quarter 9:00 UTC' },
];
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When set, the dialog edits an existing schedule. Otherwise create. */
schedule?: ReportSchedule;
/** Pre-select a template for the create flow (e.g. when the user
* triggered the dialog from a specific template detail page). */
initialTemplateId?: string;
}
interface Recipient {
name: string;
email: string;
}
/**
* Outer dialog shell — owns the open/close state and re-mounts the
* form body whenever the user switches between create / edit-N. The
* `key` on `<ScheduleDialogForm>` resets every useState initializer
* naturally when the schedule prop changes, sidestepping the
* "setState in useEffect" anti-pattern an explicit reset effect
* would otherwise need.
*/
export function ScheduleDialog({ open, onOpenChange, schedule, initialTemplateId }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ScheduleDialogForm
key={schedule?.id ?? 'new'}
schedule={schedule}
initialTemplateId={initialTemplateId}
onClose={() => onOpenChange(false)}
/>
) : null}
</Dialog>
);
}
interface FormProps {
schedule?: ReportSchedule;
initialTemplateId?: string;
onClose: () => void;
}
function ScheduleDialogForm({ schedule, initialTemplateId, onClose }: FormProps) {
const qc = useQueryClient();
const isEdit = !!schedule;
const [templateId, setTemplateId] = useState<string>(
schedule?.templateId ?? initialTemplateId ?? '',
);
const [cadence, setCadence] = useState<Cadence>(
(schedule?.cadence as Cadence) ?? 'weekly_monday_9',
);
const [outputFormat, setOutputFormat] = useState<OutputFormat>(
(schedule?.outputFormat as OutputFormat) ?? 'pdf',
);
const [enabled, setEnabled] = useState<boolean>(schedule?.enabled ?? true);
const [recipients, setRecipients] = useState<Recipient[]>(
schedule?.recipients?.map((r) => ({ name: r.name ?? '', email: r.email })) ?? [],
);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
// No `enabled` gate needed — the outer ScheduleDialog only mounts
// this form when `open=true`, so the query is implicitly off until
// the dialog actually appears.
const templatesQuery = useQuery<{ data: ReportTemplate[] }>({
queryKey: ['report-templates', 'all'],
queryFn: () => apiFetch<{ data: ReportTemplate[] }>(`/api/v1/reports/templates`),
staleTime: 30_000,
});
const createMutation = useMutation({
mutationFn: async () =>
apiFetch<{ data: ReportSchedule }>(`/api/v1/reports/schedules`, {
method: 'POST',
body: {
templateId,
cadence,
outputFormat,
enabled,
recipients: recipients.map((r) => ({
name: r.name.trim() || undefined,
email: r.email.trim(),
})),
},
}),
onSuccess: () => {
toast.success('Schedule created');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
onClose();
},
onError: (err) => toastError(err),
});
const updateMutation = useMutation({
mutationFn: async () => {
if (!schedule) throw new Error('No schedule to update');
return apiFetch<{ data: ReportSchedule }>(`/api/v1/reports/schedules/${schedule.id}`, {
method: 'PATCH',
body: {
cadence,
outputFormat,
enabled,
recipients: recipients.map((r) => ({
name: r.name.trim() || undefined,
email: r.email.trim(),
})),
},
});
},
onSuccess: () => {
toast.success('Schedule updated');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
onClose();
},
onError: (err) => toastError(err),
});
function addRecipient() {
const email = newEmail.trim();
if (!email) return;
setRecipients((prev) => [...prev, { name: newName.trim(), email }]);
setNewName('');
setNewEmail('');
}
function removeRecipient(idx: number) {
setRecipients((prev) => prev.filter((_, i) => i !== idx));
}
const submitting = createMutation.isPending || updateMutation.isPending;
const canSubmit = templateId !== '' && !submitting;
const templates = templatesQuery.data?.data ?? [];
return (
<>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit schedule' : 'New schedule'}</DialogTitle>
<DialogDescription>
Recurring report. Recipients are optional schedules with no recipients still run and
appear in the runs history, they just skip the email step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="schedule-template" className="text-xs">
Template
</Label>
<Select
value={templateId}
onValueChange={setTemplateId}
disabled={isEdit || templatesQuery.isLoading}
>
<SelectTrigger id="schedule-template">
<SelectValue
placeholder={
templatesQuery.isLoading
? 'Loading templates…'
: templates.length === 0
? 'No templates available — save one first'
: 'Pick a template'
}
/>
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} <span className="text-muted-foreground">· {t.kind}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{isEdit ? (
<p className="text-[11px] text-muted-foreground">
Template can&apos;t be changed on an existing schedule. Delete + recreate to
re-bind.
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="schedule-cadence" className="text-xs">
Cadence
</Label>
<Select value={cadence} onValueChange={(v) => setCadence(v as Cadence)}>
<SelectTrigger id="schedule-cadence">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CADENCE_OPTIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="schedule-format" className="text-xs">
Output
</Label>
<Select
value={outputFormat}
onValueChange={(v) => setOutputFormat(v as OutputFormat)}
>
<SelectTrigger id="schedule-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
CSV/XLSX coming for scheduled runs use Export for those formats now.
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Recipients (optional)</Label>
<div className="space-y-1.5">
{recipients.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
No recipients yet runs will be archived but not emailed.
</p>
) : (
recipients.map((r, idx) => (
<div
key={`${r.email}-${idx}`}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
>
<div className="flex-1">
<span className="font-medium">{r.name || r.email}</span>
{r.name ? (
<span className="ml-2 text-xs text-muted-foreground">{r.email}</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => removeRecipient(idx)}
aria-label="Remove recipient"
>
<X className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
))
)}
</div>
<div className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input
placeholder="Name (optional)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-9"
/>
<Input
type="email"
placeholder="email@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addRecipient();
}
}}
className="h-9"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRecipient}
disabled={!newEmail.trim()}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Add
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Switch id="schedule-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="schedule-enabled" className="cursor-pointer text-sm">
Enabled
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={() => (isEdit ? updateMutation.mutate() : createMutation.mutate())}
disabled={!canSubmit}
>
{submitting ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
{isEdit ? 'Save changes' : 'Create schedule'}
</Button>
</DialogFooter>
</DialogContent>
</>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { useState } from 'react';
import { Download, FileSpreadsheet, FileText, Sheet } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { defaultCsvFilename, exportReportAsCsv } from '@/lib/reports/exporters/csv';
import { defaultPdfFilename, exportReportAsPdf } from '@/lib/reports/exporters/pdf';
import { defaultXlsxFilename, exportReportAsXlsx } from '@/lib/reports/exporters/xlsx';
import type { ExportResult, ReportPayload } from '@/lib/reports/types';
/** Supported formats. Excel + PDF are scaffolded UI; only CSV is wired. */
type ExportFormat = 'csv' | 'xlsx' | 'pdf';
interface ReportExportButtonProps {
/** Function that produces the ReportPayload at click time.
* Lazy: only invoked when the user picks a format, so building the
* payload (which may involve formatting numbers + dates from the
* live report state) doesn't run on every render. */
buildPayload: () => ReportPayload;
/** Disable the button (e.g. while the report query is loading). */
disabled?: boolean;
}
/**
* Shared export dropdown for every report. Three format options:
*
* - CSV: working today via `papaparse`. Multi-section flat file.
* - Excel: scaffolded — wires through to the same payload but the
* `exportReportAsXlsx` implementation lands as Task #35.
* - PDF: scaffolded — same payload, branded shell wraps the output.
* Lands as Task #34.
*
* Format-disabled items render as disabled menu items with a "coming
* soon" caption rather than being hidden, so the affordance is
* discoverable across the platform from day one.
*/
export function ReportExportButton({ buildPayload, disabled }: ReportExportButtonProps) {
const [exporting, setExporting] = useState(false);
// Pending-format dialog state: when the user picks a format from the
// dropdown, we capture that intent + open a rename dialog so they
// can override the title (which is baked into both the filename AND
// the document's header). The actual export fires from the dialog's
// confirm button.
const [pendingFormat, setPendingFormat] = useState<ExportFormat | null>(null);
const [customTitle, setCustomTitle] = useState<string>('');
function openRenameDialog(format: ExportFormat) {
// Pre-fill with the current report's title so the user only types
// when they want to override.
try {
const payload = buildPayload();
setCustomTitle(payload.title);
setPendingFormat(format);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not prepare export');
}
}
async function handleConfirm() {
if (!pendingFormat) return;
setExporting(true);
try {
// Rebuild payload at export time so any background-state changes
// (e.g. the rep just picked a different date range) are reflected.
const basePayload = buildPayload();
const trimmedTitle = customTitle.trim();
const titleChanged = trimmedTitle && trimmedTitle !== basePayload.title;
const titledPayload: ReportPayload = {
...basePayload,
title: trimmedTitle || basePayload.title,
};
// When the user has CUSTOMISED the title, use it verbatim as the
// filename (no auto-appended date suffix — they typed a meaningful
// name, respect it). When they kept the default, fall back to the
// exporter's standard `slug-fromdate_todate.<ext>` pattern so
// historical downloads stay disambiguated.
const filenameOverride = titleChanged
? `${slugify(trimmedTitle)}.${pendingFormat}`
: undefined;
let result: ExportResult;
if (pendingFormat === 'csv') {
result = exportReportAsCsv(titledPayload, { filenameOverride });
} else if (pendingFormat === 'xlsx') {
result = await exportReportAsXlsx(titledPayload, { filenameOverride });
} else if (pendingFormat === 'pdf') {
result = await exportReportAsPdf(titledPayload, { filenameOverride });
} else {
throw new Error(`${String(pendingFormat).toUpperCase()} export is not wired`);
}
downloadResult(result);
toast.success(`Downloaded ${result.filename}`);
setPendingFormat(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
} finally {
setExporting(false);
}
}
/**
* Live filename preview for the dialog. Mirrors the same branching as
* `handleConfirm` so what you see is what you get — custom title →
* verbatim slug, default title → date-suffixed standard.
*/
function previewFilename(): string {
try {
const base = buildPayload();
const trimmed = customTitle.trim();
const changed = trimmed && trimmed !== base.title;
const ext = pendingFormat ?? 'csv';
if (changed) {
return `${slugify(trimmed) || 'report'}.${ext}`;
}
// Default pattern is exporter-specific.
if (ext === 'csv') return defaultCsvFilename(base);
if (ext === 'xlsx') return defaultXlsxFilename(base);
if (ext === 'pdf') return defaultPdfFilename(base);
return `${base.filenameSlug}-${todaySlug()}.${ext}`;
} catch {
return `report.${pendingFormat ?? 'csv'}`;
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled || exporting}>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Download report
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openRenameDialog('csv')}>
<FileText className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openRenameDialog('xlsx')}>
<FileSpreadsheet className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">Excel</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openRenameDialog('pdf')}>
<Sheet className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">PDF</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={pendingFormat !== null}
onOpenChange={(open) => {
if (!open) setPendingFormat(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Name your export</DialogTitle>
<DialogDescription>
This title appears at the top of the file and is used as the filename. Leave it as- is
for the default report name.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="export-title-input" className="text-xs text-muted-foreground">
Title
</Label>
<Input
id="export-title-input"
autoFocus
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !exporting) {
e.preventDefault();
handleConfirm();
}
}}
placeholder="e.g. Q2 sales review for board"
/>
<p className="text-[11px] text-muted-foreground">
Filename: <code className="font-mono">{previewFilename()}</code>
</p>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingFormat(null)}
disabled={exporting}
>
Cancel
</Button>
<Button size="sm" onClick={handleConfirm} disabled={exporting}>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
{exporting ? 'Downloading…' : 'Download'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/**
* File-safe slug from an arbitrary title. Lowercases, replaces runs
* of non-alphanumerics with single hyphens, trims leading/trailing
* hyphens. Cap at 80 chars so OS file dialogs don't get an essay.
*/
function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
function todaySlug(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Trigger a browser download for an ExportResult. The blob URL is
* revoked after the click so we don't leak object URLs on long-lived
* sessions where the user exports many reports.
*/
function downloadResult(result: ExportResult): void {
const url = URL.createObjectURL(result.body);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

View File

@@ -0,0 +1,338 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bookmark, Check, Loader2, Save, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { ReportTemplate } from '@/lib/db/schema/reports';
type StandaloneReportKind = 'sales' | 'operational' | 'custom';
interface ListResponse {
data: ReportTemplate[];
}
interface ReportTemplatesButtonProps<TConfig extends Record<string, unknown>> {
/** Discriminator on the saved template row. Must match the report
* page; cross-kind templates are filtered out of the dropdown. */
kind: StandaloneReportKind;
/** Snapshot of the report's current view state. Save flows persist
* this verbatim; Load flows hand it back via onApply. */
currentConfig: TConfig;
/** Apply a loaded config to the report's local state. The component
* passes the entire `config` object back; the report client picks
* off whatever keys it knows about. */
onApply: (config: TConfig) => void;
/** Set after a load so the UI can show "Using template X". When the
* user changes any view-state (range, filter, etc.) downstream of
* load, the parent should null this back out so the badge clears. */
activeTemplateId?: string | null;
/** Optional callback so the parent can reflect template-load /
* template-clear in URL state. */
onActiveTemplateChange?: (id: string | null) => void;
/** Optional pre-selection: if the URL carried a `?templateId=…`,
* pass it in here and the component will hydrate + apply on mount. */
initialTemplateId?: string | null;
}
/**
* Combined Save + Load + Delete control for the standalone Sales and
* Operational reports. One trigger button (with a "Using template X"
* indicator), opens a popover that lists saved templates and offers
* "Save as new template…".
*
* Schema: report_templates rows with kind ∈ {sales, operational}.
* Config payload shape is owner-defined per report.
*/
export function ReportTemplatesButton<TConfig extends Record<string, unknown>>({
kind,
currentConfig,
onApply,
activeTemplateId,
onActiveTemplateChange,
initialTemplateId,
}: ReportTemplatesButtonProps<TConfig>) {
const qc = useQueryClient();
const [popoverOpen, setPopoverOpen] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [saveName, setSaveName] = useState('');
const [saveDescription, setSaveDescription] = useState('');
// Ref instead of state for the one-time hydration guard so we can
// update it without triggering a re-render (and without tripping
// react-hooks/set-state-in-effect on the surrounding useEffect).
const hydratedRef = useRef(false);
const listQuery = useQuery<ListResponse>({
queryKey: ['report-templates', kind],
queryFn: () =>
apiFetch<ListResponse>(`/api/v1/reports/templates?kind=${encodeURIComponent(kind)}`),
staleTime: 30_000,
});
// Hydrate from ?templateId=… on first render once the list lands.
useEffect(() => {
if (hydratedRef.current) return;
if (!initialTemplateId) return;
if (!listQuery.data) return;
const found = listQuery.data.data.find((t) => t.id === initialTemplateId);
if (found) {
onApply(found.config as TConfig);
onActiveTemplateChange?.(found.id);
}
hydratedRef.current = true;
}, [initialTemplateId, listQuery.data, onApply, onActiveTemplateChange]);
const saveMutation = useMutation({
mutationFn: async (input: { name: string; description: string | null }) => {
const body = {
kind,
name: input.name,
description: input.description,
// The schema-level `config.kind` cross-check on the API requires
// the discriminator to live on the payload itself.
config: { ...currentConfig, kind },
};
return apiFetch<{ data: ReportTemplate }>(`/api/v1/reports/templates`, {
method: 'POST',
body,
});
},
onSuccess: ({ data }) => {
toast.success(`Template "${data.name}" saved`);
setSaveDialogOpen(false);
setSaveName('');
setSaveDescription('');
onActiveTemplateChange?.(data.id);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await apiFetch(`/api/v1/reports/templates/${id}`, { method: 'DELETE' });
},
onSuccess: (_, id) => {
toast.success('Template deleted');
if (activeTemplateId === id) onActiveTemplateChange?.(null);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
const updateMutation = useMutation({
mutationFn: async (id: string) => {
return apiFetch<{ data: ReportTemplate }>(`/api/v1/reports/templates/${id}`, {
method: 'PATCH',
body: { config: { ...currentConfig, kind } },
});
},
onSuccess: ({ data }) => {
toast.success(`Template "${data.name}" updated`);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
function handleApply(template: ReportTemplate) {
onApply(template.config as TConfig);
onActiveTemplateChange?.(template.id);
setPopoverOpen(false);
}
const templates = listQuery.data?.data ?? [];
const activeTemplate = activeTemplateId
? templates.find((t) => t.id === activeTemplateId)
: undefined;
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Bookmark className="mr-1.5 h-4 w-4" aria-hidden />
{activeTemplate ? `Template: ${activeTemplate.name}` : 'Templates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="end">
<div className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Saved templates
</p>
{listQuery.isLoading ? (
<p className="mt-1 text-xs text-muted-foreground">Loading</p>
) : templates.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">
No saved templates yet. Save your current view below.
</p>
) : (
<ul className="mt-1 max-h-56 overflow-y-auto space-y-0.5">
{templates.map((t) => {
const isActive = t.id === activeTemplateId;
return (
<li
key={t.id}
className="group flex items-center gap-1 rounded-sm px-1 py-0.5 hover:bg-muted/50"
>
<button
type="button"
onClick={() => handleApply(t)}
className="flex-1 text-left text-sm"
>
<span className="flex items-center gap-1.5">
{isActive ? (
<Check className="h-3.5 w-3.5 text-primary" aria-hidden />
) : (
<span className="h-3.5 w-3.5" aria-hidden />
)}
<span>{t.name}</span>
</span>
{t.description ? (
<p className="pl-5 text-[11px] text-muted-foreground line-clamp-1">
{t.description}
</p>
) : null}
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => deleteMutation.mutate(t.id)}
disabled={deleteMutation.isPending}
aria-label={`Delete template ${t.name}`}
title="Delete this template"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" aria-hidden />
</Button>
</li>
);
})}
</ul>
)}
</div>
<Separator />
<div className="space-y-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
setPopoverOpen(false);
setSaveDialogOpen(true);
}}
>
<Save className="mr-1.5 h-4 w-4" aria-hidden />
Save current view as template
</Button>
{activeTemplate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
updateMutation.mutate(activeTemplate.id);
setPopoverOpen(false);
}}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
Update &quot;{activeTemplate.name}&quot;
</Button>
) : null}
</div>
</div>
</PopoverContent>
</Popover>
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save report as template</DialogTitle>
<DialogDescription>
The current date range and filter selection are captured. Re-run the report from this
template in one click from the Reports landing page or the Templates list.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="template-name" className="text-xs">
Name
</Label>
<Input
id="template-name"
autoFocus
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="e.g. Monthly board sales view"
/>
</div>
<div className="space-y-1">
<Label htmlFor="template-description" className="text-xs">
Description (optional)
</Label>
<Textarea
id="template-description"
value={saveDescription}
onChange={(e) => setSaveDescription(e.target.value)}
placeholder="Helpful note about what this template is for"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setSaveDialogOpen(false)}
disabled={saveMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={() =>
saveMutation.mutate({
name: saveName.trim(),
description: saveDescription.trim() || null,
})
}
disabled={!saveName.trim() || saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
Save template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Calendar, Pencil, Play, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
@@ -22,11 +23,16 @@ import {
} 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';
import { toastError } from '@/lib/api/toast-error';
import { ScheduleDialog } from '@/components/reports/schedule-dialog';
import type { ReportSchedule, ReportTemplate } from '@/lib/db/schema/reports';
interface ListResponse {
interface SchedulesResponse {
data: ReportSchedule[];
}
interface TemplatesResponse {
data: ReportTemplate[];
}
const CADENCE_LABELS: Record<string, string> = {
weekly_monday_9: 'Weekly · Monday 9am',
@@ -36,9 +42,20 @@ const CADENCE_LABELS: Record<string, string> = {
export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
const qc = useQueryClient();
const { data, isLoading } = useQuery<ListResponse>({
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ReportSchedule | undefined>(undefined);
const schedulesQuery = useQuery<SchedulesResponse>({
queryKey: ['report-schedules'],
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/schedules?limit=50'),
queryFn: () => apiFetch<SchedulesResponse>('/api/v1/reports/schedules?pageSize=50'),
});
// Pull all templates so we can resolve template_id → name in the
// table without N round-trips. One extra query, cheap, port-scoped.
const templatesQuery = useQuery<TemplatesResponse>({
queryKey: ['report-templates', 'all'],
queryFn: () => apiFetch<TemplatesResponse>('/api/v1/reports/templates'),
staleTime: 30_000,
});
const toggleMutation = useMutation({
@@ -50,36 +67,83 @@ export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
},
onSuccess: () => {
toast.success('Schedule updated');
qc.invalidateQueries({ queryKey: ['report-schedules'] });
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
onError: (err) => toastError(err),
});
const rows = data?.data ?? [];
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
return apiFetch(`/api/v1/reports/schedules/${id}`, { method: 'DELETE' });
},
onSuccess: () => {
toast.success('Schedule deleted');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
},
onError: (err) => toastError(err),
});
const runNowMutation = useMutation({
mutationFn: async (schedule: ReportSchedule) => {
const tmpl = templatesQuery.data?.data.find((t) => t.id === schedule.templateId);
if (!tmpl) throw new Error('Template no longer exists; cannot run.');
return apiFetch(`/api/v1/reports/runs`, {
method: 'POST',
body: {
kind: tmpl.kind,
templateId: tmpl.id,
// Re-stamp the discriminator onto config — the run-create
// route's same cross-check requires config.kind === kind.
config: { ...(tmpl.config as Record<string, unknown>), kind: tmpl.kind },
outputFormat: schedule.outputFormat,
},
});
},
onSuccess: () => {
toast.success('Run queued — check Runs tab in a few seconds');
void qc.invalidateQueries({ queryKey: ['report-runs'] });
},
onError: (err) => toastError(err),
});
const templateById = new Map(templatesQuery.data?.data?.map((t) => [t.id, t]) ?? []);
const rows = schedulesQuery.data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
eyebrow="Reports"
title="Schedules"
description="Recurring reports auto-emailed to your recipient list."
description="Recurring reports that auto-run and (optionally) email a recipient list."
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>
<div className="flex items-center gap-2">
<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>
<Button
size="sm"
onClick={() => {
setEditing(undefined);
setDialogOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
New schedule
</Button>
</div>
}
/>
{isLoading ? (
{schedulesQuery.isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : rows.length === 0 ? (
<EmptyState
icon={Calendar}
title="No schedules yet"
description="Save a template, then schedule it from the template detail page."
description="Create a schedule against a saved template. Recipients are optional — runs are archived even without an email blast."
/>
) : (
<Card>
@@ -87,48 +151,119 @@ export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
<Table>
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Recipients</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Next run</TableHead>
<TableHead>Output</TableHead>
<TableHead className="w-20 text-right">Enabled</TableHead>
<TableHead className="w-32 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
{CADENCE_LABELS[s.cadence] ?? s.cadence}
</TableCell>
<TableCell>
<Badge variant="outline">
{Array.isArray(s.recipients) ? s.recipients.length : 0}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(s.nextRunAt).toLocaleString()}
</TableCell>
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
{s.outputFormat}
</TableCell>
<TableCell className="text-right">
<Switch
checked={s.enabled}
onCheckedChange={(enabled) => toggleMutation.mutate({ id: s.id, enabled })}
disabled={toggleMutation.isPending}
/>
</TableCell>
</TableRow>
))}
{rows.map((s) => {
const tmpl = templateById.get(s.templateId);
const recipientCount = Array.isArray(s.recipients) ? s.recipients.length : 0;
return (
<TableRow key={s.id}>
<TableCell className="font-medium">
{tmpl ? (
<>
{tmpl.name}
<span className="ml-1.5 text-xs text-muted-foreground capitalize">
· {tmpl.kind}
</span>
</>
) : (
<span className="text-muted-foreground italic">template missing</span>
)}
</TableCell>
<TableCell>{CADENCE_LABELS[s.cadence] ?? s.cadence}</TableCell>
<TableCell>
{recipientCount === 0 ? (
<Badge variant="outline" className="text-muted-foreground">
archive only
</Badge>
) : (
<Badge variant="outline">{recipientCount}</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(s.nextRunAt).toLocaleString()}
</TableCell>
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
{s.outputFormat}
</TableCell>
<TableCell className="text-right">
<Switch
checked={s.enabled}
onCheckedChange={(enabled) =>
toggleMutation.mutate({ id: s.id, enabled })
}
disabled={toggleMutation.isPending}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => runNowMutation.mutate(s)}
disabled={runNowMutation.isPending || !tmpl}
aria-label="Run now"
title="Run this schedule now (one-off)"
>
<Play className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditing(s);
setDialogOpen(true);
}}
aria-label="Edit schedule"
title="Edit schedule"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
if (
confirm(
`Delete schedule? This stops the recurring run; existing runs in the history stay.`,
)
) {
deleteMutation.mutate(s.id);
}
}}
disabled={deleteMutation.isPending}
aria-label="Delete schedule"
title="Delete schedule"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" aria-hidden />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<ScheduleDialog open={dialogOpen} onOpenChange={setDialogOpen} schedule={editing} />
</div>
);
}