- 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>
554 lines
20 KiB
TypeScript
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 ·{' '}
|
|
{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>
|
|
);
|
|
}
|