Files
campaign-command-center/src/app/(dashboard)/analytics/forecast/page.tsx
Chris Olson d79794b164 Add Phase 3: Analytics & Forecast Intelligence
- Pipeline Movement Tracker with waterfall chart and snapshot capture
- Forecast Call Workspace with inline deal overrides and lock history
- Forecast Accuracy tracking against locked snapshots
- Win/Loss Analysis with rate breakdowns by stage, competitor, deal size, play
- Playbook Effectiveness dashboard with conversion funnel and step drop-off
- Account engagement trends with 12-week sparklines and direction badges
- Analytics sidebar navigation with collapsible submenu
- Data layer: forecast_locks, forecast_overrides tables, pipeline movement API
- Fix Turbopack function hoisting issues in Sidebar and db.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-01 13:49:05 -04:00

554 lines
20 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { Scorecard } from '@/components/ui/Scorecard';
import { formatCurrency, CHART_COLORS } from '@/lib/formatters';
import { useData } from '@/lib/data-context';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
/* ---------- Types ---------- */
interface ForecastOverride {
id: string;
Opportunity_ID: string;
override_date: string;
original_amount: number;
override_amount: number;
original_category: string;
override_category: string;
original_close_date?: string;
override_close_date?: string;
reason?: string;
}
interface ForecastLock {
id: string;
lock_date: string;
quarter: string;
locked_by: string;
notes: string;
total_commit: number;
total_best_case: number;
total_pipeline: number;
deals_json: string;
}
interface PipelineDeal {
Opportunity_ID: string;
Account_Name: string;
District_Name: string;
Stage: string;
Amount_USD: number;
Forecast_Category: string;
Expected_Close_Date: string;
Probability_Pct: number;
}
/* ---------- Constants ---------- */
const QUARTERS = ['Q1 FY27', 'Q2 FY27', 'Q3 FY27', 'Q4 FY27'];
const DEFAULT_QUOTA = 2_000_000;
const CLOSED_STAGES = ['06-Closed Won', '07-Closed Lost'];
const CATEGORY_ORDER: Record<string, number> = { Commit: 0, 'Best Case': 1, Pipeline: 2 };
function currentQuarter(): string {
// Today is ~2026-09-01 which maps to Q3 FY27
return 'Q3 FY27';
}
/* ---------- Helpers ---------- */
async function apiFetch<T>(action: string, body: Record<string, unknown> = {}): Promise<T> {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...body }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/* ---------- Sub-components ---------- */
function LockDialog({
open,
message,
notes,
onNotesChange,
onConfirm,
onCancel,
confirming,
}: {
open: boolean;
message: string;
notes: string;
onNotesChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
confirming: boolean;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-card-bg border border-card-border rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-foreground mb-2">Lock Forecast</h3>
<p className="text-sm text-muted mb-4">{message}</p>
<input
type="text"
placeholder="Optional notes..."
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
className="w-full bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted mb-6"
/>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm rounded-lg border border-card-border text-muted hover:bg-card-bg transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={confirming}
className="px-4 py-2 text-sm rounded-lg bg-brand-azure text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{confirming ? 'Locking...' : 'Lock Forecast'}
</button>
</div>
</div>
</div>
);
}
function InlineEdit({
value,
type,
options,
onSave,
}: {
value: string;
type: 'text' | 'number' | 'date' | 'select';
options?: string[];
onSave: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="text-left hover:text-brand-azure transition-colors underline-offset-2 hover:underline"
title="Click to edit"
>
{type === 'number' ? formatCurrency(Number(value)) : value}
</button>
);
}
const commit = () => {
if (draft !== value) onSave(draft);
setEditing(false);
};
if (type === 'select') {
return (
<select
autoFocus
value={draft}
onChange={(e) => { setDraft(e.target.value); }}
onBlur={commit}
className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground"
>
{options?.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
}
return (
<input
autoFocus
type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground w-28"
/>
);
}
/* ---------- Main Page ---------- */
export default function ForecastWorkspacePage() {
const { filtered } = useData();
const { pipeline } = filtered;
const [quarter, setQuarter] = useState(currentQuarter);
const [overrides, setOverrides] = useState<ForecastOverride[]>([]);
const [locks, setLocks] = useState<ForecastLock[]>([]);
const [loading, setLoading] = useState(true);
const [lockDialogOpen, setLockDialogOpen] = useState(false);
const [locking, setLocking] = useState(false);
const [lockNotes, setLockNotes] = useState('');
/* -- Load overrides & locks -- */
const loadData = useCallback(async () => {
setLoading(true);
try {
const [ovRes, lockRes] = await Promise.all([
apiFetch<{ data: ForecastOverride[] }>('forecast.overrides'),
apiFetch<{ data: ForecastLock[] }>('forecast.locks', { quarter }),
]);
setOverrides(ovRes.data ?? []);
setLocks(lockRes.data ?? []);
} catch {
// silently handle — data will be empty
} finally {
setLoading(false);
}
}, [quarter]);
useEffect(() => { loadData(); }, [loadData]);
/* -- Build effective deals: pipeline filtered to open, with overrides applied -- */
const overrideMap = useMemo(() => {
const m = new Map<string, ForecastOverride>();
for (const o of overrides) m.set(o.Opportunity_ID, o);
return m;
}, [overrides]);
const openDeals = useMemo(() => {
return (pipeline as PipelineDeal[])
.filter((d) => !CLOSED_STAGES.includes(d.Stage))
.map((d) => {
const ov = overrideMap.get(d.Opportunity_ID);
return {
...d,
Amount_USD: ov ? ov.override_amount : d.Amount_USD,
Forecast_Category: ov ? ov.override_category : d.Forecast_Category,
Expected_Close_Date: ov?.override_close_date ?? d.Expected_Close_Date,
_overridden: !!ov,
_original: d,
};
})
.filter((d) => ['Commit', 'Best Case', 'Pipeline'].includes(d.Forecast_Category));
}, [pipeline, overrideMap]);
/* -- Aggregates -- */
const totals = useMemo(() => {
let commit = 0, bestCase = 0, pipe = 0;
for (const d of openDeals) {
if (d.Forecast_Category === 'Commit') commit += d.Amount_USD;
else if (d.Forecast_Category === 'Best Case') bestCase += d.Amount_USD;
else if (d.Forecast_Category === 'Pipeline') pipe += d.Amount_USD;
}
const coverage = DEFAULT_QUOTA > 0 ? (commit + bestCase) / DEFAULT_QUOTA : 0;
return { commit, bestCase, pipeline: pipe, coverage };
}, [openDeals]);
/* -- Grouped deals -- */
const grouped = useMemo(() => {
const groups: Record<string, typeof openDeals> = { Commit: [], 'Best Case': [], Pipeline: [] };
for (const d of openDeals) {
if (groups[d.Forecast_Category]) groups[d.Forecast_Category].push(d);
}
// Sort each group by amount desc
for (const k of Object.keys(groups)) {
groups[k].sort((a, b) => b.Amount_USD - a.Amount_USD);
}
return groups;
}, [openDeals]);
/* -- Chart data: stacked by district -- */
const chartData = useMemo(() => {
const byDistrict: Record<string, { Commit: number; 'Best Case': number; Pipeline: number }> = {};
for (const d of openDeals) {
if (!byDistrict[d.District_Name]) {
byDistrict[d.District_Name] = { Commit: 0, 'Best Case': 0, Pipeline: 0 };
}
const cat = d.Forecast_Category as 'Commit' | 'Best Case' | 'Pipeline';
byDistrict[d.District_Name][cat] += d.Amount_USD;
}
return Object.entries(byDistrict)
.map(([name, vals]) => ({ district: name, ...vals }))
.sort((a, b) => (b.Commit + b['Best Case'] + b.Pipeline) - (a.Commit + a['Best Case'] + a.Pipeline));
}, [openDeals]);
/* -- Override handler -- */
const handleOverride = useCallback(
async (deal: (typeof openDeals)[0], field: 'Amount_USD' | 'Forecast_Category' | 'Expected_Close_Date', newValue: string) => {
const orig = deal._original;
const current = { ...deal };
if (field === 'Amount_USD') current.Amount_USD = Number(newValue);
else if (field === 'Forecast_Category') current.Forecast_Category = newValue;
else if (field === 'Expected_Close_Date') current.Expected_Close_Date = newValue;
try {
await apiFetch('forecast.override', {
Opportunity_ID: orig.Opportunity_ID,
original_amount: orig.Amount_USD,
override_amount: field === 'Amount_USD' ? Number(newValue) : current.Amount_USD,
original_category: orig.Forecast_Category,
override_category: field === 'Forecast_Category' ? newValue : current.Forecast_Category,
original_close_date: orig.Expected_Close_Date,
override_close_date: field === 'Expected_Close_Date' ? newValue : current.Expected_Close_Date,
reason: 'Forecast workspace edit',
});
await loadData();
} catch {
// silently handle
}
},
[loadData],
);
/* -- Lock handler -- */
const handleLock = useCallback(async () => {
setLocking(true);
try {
await apiFetch('forecast.lock', { quarter, notes: lockNotes });
setLockDialogOpen(false);
setLockNotes('');
await loadData();
} catch {
// silently handle
} finally {
setLocking(false);
}
}, [quarter, lockNotes, loadData]);
/* ---------- Render ---------- */
return (
<div className="space-y-6">
<PageHeader title="Forecast Workspace" />
{/* Quarter selector & Lock button */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-muted">Quarter</label>
<select
value={quarter}
onChange={(e) => setQuarter(e.target.value)}
className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground"
>
{QUARTERS.map((q) => (
<option key={q} value={q}>{q}</option>
))}
</select>
</div>
<button
onClick={() => setLockDialogOpen(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-brand-navy text-white hover:opacity-90 transition-opacity"
>
Lock Forecast
</button>
</div>
{/* Scorecards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Scorecard label="Commit" value={formatCurrency(totals.commit, true)} color="green" />
<Scorecard label="Best Case" value={formatCurrency(totals.bestCase, true)} />
<Scorecard label="Pipeline" value={formatCurrency(totals.pipeline, true)} />
<Scorecard
label="Coverage"
value={`${totals.coverage.toFixed(1)}x`}
subtitle={`vs ${formatCurrency(DEFAULT_QUOTA, true)} quota`}
color={totals.coverage >= 1 ? 'green' : 'red'}
/>
</div>
{/* Loading indicator */}
{loading && (
<div className="text-center py-8 text-muted text-sm">Loading forecast data...</div>
)}
{/* Deal table grouped by category */}
<ChartCard title="Deals by Forecast Category" subtitle="Click any value to override">
<div className="space-y-6">
{(['Commit', 'Best Case', 'Pipeline'] as const).map((cat) => (
<div key={cat}>
<div className="flex items-center gap-2 mb-2">
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor:
cat === 'Commit' ? CHART_COLORS.navy : cat === 'Best Case' ? CHART_COLORS.azure : CHART_COLORS.green,
}}
/>
<h4 className="text-sm font-semibold text-foreground">{cat}</h4>
<span className="text-xs text-muted">
({grouped[cat]?.length ?? 0} deals &middot;{' '}
{formatCurrency(grouped[cat]?.reduce((s, d) => s + d.Amount_USD, 0) ?? 0, true)})
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-card-border">
<th className="pb-2 pr-4 font-medium">Account</th>
<th className="pb-2 pr-4 font-medium">Opportunity</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Amount</th>
<th className="pb-2 pr-4 font-medium">Category</th>
<th className="pb-2 font-medium">Close Date</th>
</tr>
</thead>
<tbody>
{(grouped[cat] ?? []).map((deal) => (
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-card-bg/80">
<td className="py-2 pr-4 text-foreground">
{deal.Account_Name}
{deal._overridden && (
<span className="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-brand-azure/10 text-brand-azure">
edited
</span>
)}
</td>
<td className="py-2 pr-4 text-muted font-mono">{deal.Opportunity_ID}</td>
<td className="py-2 pr-4 text-muted">{deal.Stage}</td>
<td className="py-2 pr-4 text-right">
<InlineEdit
value={String(deal.Amount_USD)}
type="number"
onSave={(v) => handleOverride(deal, 'Amount_USD', v)}
/>
</td>
<td className="py-2 pr-4">
<InlineEdit
value={deal.Forecast_Category}
type="select"
options={['Commit', 'Best Case', 'Pipeline', 'Omit']}
onSave={(v) => handleOverride(deal, 'Forecast_Category', v)}
/>
</td>
<td className="py-2">
<InlineEdit
value={deal.Expected_Close_Date}
type="date"
onSave={(v) => handleOverride(deal, 'Expected_Close_Date', v)}
/>
</td>
</tr>
))}
{(grouped[cat] ?? []).length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-center text-muted">No deals</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
))}
</div>
</ChartCard>
{/* Stacked bar chart by district */}
<ChartCard title="Forecast by District" subtitle="Commit vs Best Case vs Pipeline">
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border)" />
<XAxis dataKey="district" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis
tickFormatter={(v: number) => formatCurrency(v, true)}
tick={{ fontSize: 11, fill: 'var(--color-muted)' }}
/>
<Tooltip
formatter={(value) => formatCurrency(value as number)}
contentStyle={{
backgroundColor: 'var(--color-card-bg)',
border: '1px solid var(--color-card-border)',
borderRadius: '8px',
fontSize: '12px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="Commit" stackId="a" fill={CHART_COLORS.navy} radius={[0, 0, 0, 0]} />
<Bar dataKey="Best Case" stackId="a" fill={CHART_COLORS.azure} />
<Bar dataKey="Pipeline" stackId="a" fill={CHART_COLORS.green} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
{/* Forecast History */}
<ChartCard title="Forecast History" subtitle="Past locked snapshots">
{locks.length === 0 && !loading ? (
<p className="text-sm text-muted py-4 text-center">No forecast locks recorded yet for {quarter}.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-card-border">
<th className="pb-2 pr-4 font-medium">Lock Date</th>
<th className="pb-2 pr-4 font-medium">Locked By</th>
<th className="pb-2 pr-4 font-medium text-right">Commit</th>
<th className="pb-2 pr-4 font-medium text-right">Best Case</th>
<th className="pb-2 pr-4 font-medium text-right">Pipeline</th>
<th className="pb-2 font-medium">Notes</th>
<th className="pb-2 font-medium text-right">vs Current</th>
</tr>
</thead>
<tbody>
{locks.map((lock) => {
const commitDelta = totals.commit - lock.total_commit;
return (
<tr key={lock.id} className="border-b border-card-border/50">
<td className="py-2 pr-4 text-foreground">
{new Date(lock.lock_date).toLocaleDateString()}
</td>
<td className="py-2 pr-4 text-muted">{lock.locked_by}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_commit, true)}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_best_case, true)}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_pipeline, true)}</td>
<td className="py-2 pr-4 text-muted">{lock.notes || '—'}</td>
<td className="py-2 text-right">
<span className={commitDelta >= 0 ? 'text-success' : 'text-danger'}>
{commitDelta >= 0 ? '+' : ''}
{formatCurrency(commitDelta, true)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</ChartCard>
{/* Lock Confirm Dialog */}
<LockDialog
open={lockDialogOpen}
message={`Save a snapshot of the current ${quarter} forecast? This records Commit: ${formatCurrency(totals.commit, true)}, Best Case: ${formatCurrency(totals.bestCase, true)}, Pipeline: ${formatCurrency(totals.pipeline, true)}.`}
notes={lockNotes}
onNotesChange={setLockNotes}
onConfirm={handleLock}
onCancel={() => { setLockDialogOpen(false); setLockNotes(''); }}
confirming={locking}
/>
</div>
);
}