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:
408
src/components/reports/custom/custom-report-builder.tsx
Normal file
408
src/components/reports/custom/custom-report-builder.tsx
Normal 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, '""')}"`;
|
||||
}
|
||||
129
src/components/reports/operational/operational-heatmap.tsx
Normal file
129
src/components/reports/operational/operational-heatmap.tsx
Normal 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' });
|
||||
}
|
||||
1047
src/components/reports/operational/operational-report-client.tsx
Normal file
1047
src/components/reports/operational/operational-report-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
160
src/components/reports/sales/sales-deal-heat.tsx
Normal file
160
src/components/reports/sales/sales-deal-heat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
488
src/components/reports/sales/sales-detail-tables.tsx
Normal file
488
src/components/reports/sales/sales-detail-tables.tsx
Normal 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'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'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
|
||||
"don't drop these" 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>;
|
||||
}
|
||||
133
src/components/reports/sales/sales-pipeline-funnel.tsx
Normal file
133
src/components/reports/sales/sales-pipeline-funnel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/components/reports/sales/sales-rep-leaderboard.tsx
Normal file
140
src/components/reports/sales/sales-rep-leaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
846
src/components/reports/sales/sales-report-client.tsx
Normal file
846
src/components/reports/sales/sales-report-client.tsx
Normal 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'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'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;
|
||||
126
src/components/reports/sales/sales-source-conversion.tsx
Normal file
126
src/components/reports/sales/sales-source-conversion.tsx
Normal 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(', ');
|
||||
}
|
||||
132
src/components/reports/sales/sales-stage-velocity.tsx
Normal file
132
src/components/reports/sales/sales-stage-velocity.tsx
Normal 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`;
|
||||
}
|
||||
145
src/components/reports/sales/sales-win-rate-over-time.tsx
Normal file
145
src/components/reports/sales/sales-win-rate-over-time.tsx
Normal 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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
357
src/components/reports/schedule-dialog.tsx
Normal file
357
src/components/reports/schedule-dialog.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
262
src/components/reports/shared/report-export-button.tsx
Normal file
262
src/components/reports/shared/report-export-button.tsx
Normal 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);
|
||||
}
|
||||
338
src/components/reports/shared/report-templates-button.tsx
Normal file
338
src/components/reports/shared/report-templates-button.tsx
Normal 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 "{activeTemplate.name}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user