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>
This commit is contained in:
2026-09-01 13:49:05 -04:00
parent a131072226
commit d79794b164
11 changed files with 2646 additions and 30 deletions

View File

@@ -100,6 +100,46 @@ function getSuggestedPriority(account: AccountRecord): { level: string; reason:
return { level: 'Low', reason: 'No compelling near-term event' };
}
function getEngagementTrend(acctActivities: ActivityRecord[]): { weeklyData: number[]; direction: 'Warming' | 'Cooling' | 'Steady' | 'New' } {
const now = new Date();
const weeks: number[] = [];
for (let w = 11; w >= 0; w--) {
const weekStart = new Date(now);
weekStart.setDate(weekStart.getDate() - (w + 1) * 7);
const weekEnd = new Date(now);
weekEnd.setDate(weekEnd.getDate() - w * 7);
const ws = weekStart.toISOString().split('T')[0];
const we = weekEnd.toISOString().split('T')[0];
weeks.push(acctActivities.filter(a => a.Activity_Date >= ws && a.Activity_Date < we).length);
}
const recent = weeks.slice(8).reduce((s, v) => s + v, 0);
const earlier = weeks.slice(4, 8).reduce((s, v) => s + v, 0);
const total = weeks.reduce((s, v) => s + v, 0);
if (total === 0) return { weeklyData: weeks, direction: 'New' };
if (recent > earlier + 1) return { weeklyData: weeks, direction: 'Warming' };
if (recent < earlier - 1) return { weeklyData: weeks, direction: 'Cooling' };
return { weeklyData: weeks, direction: 'Steady' };
}
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
const max = Math.max(...data, 1);
const w = 80;
const h = 20;
const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * h}`).join(' ');
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="flex-shrink-0">
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
const TREND_STYLES: Record<string, { color: string; bg: string; label: string }> = {
Warming: { color: '#16A34A', bg: '#DCFCE7', label: 'Warming' },
Cooling: { color: '#DC2626', bg: '#FEE2E2', label: 'Cooling' },
Steady: { color: '#0098C7', bg: '#E8F6FB', label: 'Steady' },
New: { color: '#6B7280', bg: '#F3F4F6', label: 'No Activity' },
};
export default function AccountExplorer() {
const { filtered, refresh } = useData();
const { accounts, pipeline, activities, targets } = filtered;
@@ -959,6 +999,8 @@ export default function AccountExplorer() {
const suggestion = getSuggestedPriority(account);
const mismatch = suggestion.level !== account.Priority;
const health = scoreAccountHealth(account, activities, pipeline);
const acctActs = activities.filter(a => a.Account_Name === account.Account_Name);
const trend = getEngagementTrend(acctActs);
return (
<div
key={account.Account_Name}
@@ -1011,6 +1053,13 @@ export default function AccountExplorer() {
{health.health_level} ({health.health_score})
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<MiniSparkline data={trend.weeklyData} color={TREND_STYLES[trend.direction].color} />
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold" style={{ color: TREND_STYLES[trend.direction].color, backgroundColor: TREND_STYLES[trend.direction].bg }}>
{TREND_STYLES[trend.direction].label}
</span>
<span className="text-[9px] text-muted ml-auto">12wk</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-[10px] text-muted">ARR</div>

View File

@@ -0,0 +1,457 @@
'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, formatPercent, CHART_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
import { useData } from '@/lib/data-context';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, ReferenceLine,
} from 'recharts';
interface ForecastDeal {
Opportunity_ID: string;
Account_Name: string;
Amount_USD: number;
Forecast_Category: string;
Expected_Close_Date: string;
Stage: 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;
}
function parseLockDeals(lock: ForecastLock): ForecastDeal[] {
try {
return JSON.parse(lock.deals_json) as ForecastDeal[];
} catch {
return [];
}
}
export default function ForecastAccuracyPage() {
const { filtered } = useData();
const [locks, setLocks] = useState<ForecastLock[]>([]);
const [loading, setLoading] = useState(true);
const [quarter, setQuarter] = useState('');
const [availableQuarters, setAvailableQuarters] = useState<string[]>([]);
const fetchLocks = useCallback(async (q?: string) => {
setLoading(true);
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'forecast.locks', quarter: q || undefined }),
});
const json = await res.json();
setLocks(json.data ?? []);
} catch {
setLocks([]);
} finally {
setLoading(false);
}
}, []);
// Initial fetch (all locks to discover quarters)
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'forecast.locks' }),
});
const json = await res.json();
const all: ForecastLock[] = json.data ?? [];
const quarters = Array.from(new Set(all.map(l => l.quarter))).sort();
setAvailableQuarters(quarters);
if (quarters.length > 0) {
const latestQ = quarters[quarters.length - 1];
setQuarter(latestQ);
setLocks(all.filter(l => l.quarter === latestQ));
} else {
setLocks([]);
}
} catch {
setLocks([]);
} finally {
setLoading(false);
}
})();
}, []);
// Re-fetch when quarter changes
useEffect(() => {
if (quarter) fetchLocks(quarter);
}, [quarter, fetchLocks]);
// Actual closed-won from pipeline data
const closedWon = useMemo(() => {
return filtered.pipeline.filter(d => d.Stage === '06-Closed Won');
}, [filtered.pipeline]);
const totalActual = useMemo(() => {
return closedWon.reduce((s, d) => s + (d.Closed_Amount_USD ?? d.Amount_USD), 0);
}, [closedWon]);
// Most recent lock
const latestLock = useMemo(() => {
if (locks.length === 0) return null;
return [...locks].sort((a, b) => a.lock_date.localeCompare(b.lock_date))[locks.length - 1];
}, [locks]);
const forecastAmount = latestLock?.total_commit ?? 0;
const accuracy = forecastAmount > 0 ? (totalActual / forecastAmount) * 100 : 0;
const variance = totalActual - forecastAmount;
// Line chart data: locks over time vs running actual
const chartData = useMemo(() => {
const sorted = [...locks].sort((a, b) => a.lock_date.localeCompare(b.lock_date));
return sorted.map(lock => ({
date: new Date(lock.lock_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
lockDate: lock.lock_date,
forecast: lock.total_commit,
bestCase: lock.total_best_case,
actual: totalActual,
}));
}, [locks, totalActual]);
// District-level accuracy
const districtAccuracy = useMemo(() => {
if (!latestLock) return [];
const lockDeals = parseLockDeals(latestLock);
const districtForecast: Record<string, number> = {};
lockDeals
.filter(d => d.Forecast_Category === 'Commit')
.forEach(d => {
// Find district from pipeline data
const match = filtered.pipeline.find(p => p.Opportunity_ID === d.Opportunity_ID);
const district = match?.District_Name ?? 'Unknown';
districtForecast[district] = (districtForecast[district] ?? 0) + d.Amount_USD;
});
const districtActual: Record<string, number> = {};
closedWon.forEach(d => {
districtActual[d.District_Name] = (districtActual[d.District_Name] ?? 0) + (d.Closed_Amount_USD ?? d.Amount_USD);
});
const allDistricts = Array.from(new Set([...Object.keys(districtForecast), ...Object.keys(districtActual)])).sort();
return allDistricts.map(district => {
const forecast = districtForecast[district] ?? 0;
const actual = districtActual[district] ?? 0;
const acc = forecast > 0 ? (actual / forecast) * 100 : 0;
return {
district,
displayName: DISTRICT_SHORT[district] ?? district,
forecast,
actual,
accuracy: acc,
variance: actual - forecast,
};
});
}, [latestLock, filtered.pipeline, closedWon]);
// Deal-level comparison
const dealComparison = useMemo(() => {
if (!latestLock) return [];
const lockDeals = parseLockDeals(latestLock);
const pipelineMap = new Map<string, typeof filtered.pipeline[number]>(filtered.pipeline.map(p => [p.Opportunity_ID, p]));
const lockDealIds = new Set<string>(lockDeals.filter(d => d.Forecast_Category === 'Commit').map(d => d.Opportunity_ID));
const rows: Array<{
Opportunity_ID: string;
Account_Name: string;
forecastAmount: number;
forecastCategory: string;
currentStage: string;
currentAmount: number;
status: 'won' | 'lost' | 'open' | 'surprise_win' | 'surprise_loss';
}> = [];
// Deals that were in the commit
lockDeals
.filter(d => d.Forecast_Category === 'Commit')
.forEach(d => {
const current = pipelineMap.get(d.Opportunity_ID);
let status: 'won' | 'lost' | 'open' | 'surprise_win' | 'surprise_loss' = 'open';
const currentStage = current?.Stage ?? d.Stage;
if (currentStage === '06-Closed Won') status = 'won';
else if (currentStage === '07-Closed Lost') status = 'surprise_loss';
rows.push({
Opportunity_ID: d.Opportunity_ID,
Account_Name: d.Account_Name,
forecastAmount: d.Amount_USD,
forecastCategory: d.Forecast_Category,
currentStage,
currentAmount: current?.Closed_Amount_USD ?? current?.Amount_USD ?? d.Amount_USD,
status,
});
});
// Surprise wins: closed won deals not in the commit
closedWon
.filter(d => !lockDealIds.has(d.Opportunity_ID))
.forEach(d => {
rows.push({
Opportunity_ID: d.Opportunity_ID,
Account_Name: d.Account_Name,
forecastAmount: 0,
forecastCategory: 'Not in Commit',
currentStage: d.Stage,
currentAmount: d.Closed_Amount_USD ?? d.Amount_USD,
status: 'surprise_win',
});
});
return rows.sort((a, b) => {
const order = { surprise_loss: 0, surprise_win: 1, lost: 2, open: 3, won: 4 };
return order[a.status] - order[b.status];
});
}, [latestLock, filtered.pipeline, closedWon]);
const statusBadge = (status: string) => {
const styles: Record<string, string> = {
won: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
lost: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
open: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
surprise_win: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
surprise_loss: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
};
const labels: Record<string, string> = {
won: 'Won',
lost: 'Lost',
open: 'Open',
surprise_win: 'Surprise Win',
surprise_loss: 'Surprise Miss',
};
return (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? ''}`}>
{labels[status] ?? status}
</span>
);
};
if (loading) {
return (
<>
<PageHeader title="Forecast Accuracy" />
<div className="flex items-center justify-center h-64 text-muted">Loading forecast data...</div>
</>
);
}
if (locks.length === 0 && !loading) {
return (
<>
<PageHeader title="Forecast Accuracy" />
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">No Forecast Locks Found</h2>
<p className="text-muted text-sm max-w-md">
Forecast accuracy analysis requires at least one locked forecast snapshot.
Head to the Forecast Workspace to lock your first forecast, then come back here to track accuracy over time.
</p>
</div>
<a
href="/forecast"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white"
style={{ backgroundColor: CHART_COLORS.azure }}
>
Go to Forecast Workspace
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</>
);
}
return (
<>
<PageHeader title="Forecast Accuracy" />
<div className="space-y-6">
{/* Quarter selector */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-muted">Quarter</label>
<select
className="bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-foreground"
value={quarter}
onChange={e => setQuarter(e.target.value)}
>
{availableQuarters.map(q => (
<option key={q} value={q}>{q}</option>
))}
</select>
</div>
{/* Scorecards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Scorecard
label="Forecast (Commit)"
value={formatCurrency(forecastAmount, true)}
subtitle={`Locked ${latestLock ? new Date(latestLock.lock_date).toLocaleDateString() : ''}`}
/>
<Scorecard
label="Actual Closed Won"
value={formatCurrency(totalActual, true)}
color={totalActual >= forecastAmount ? 'green' : 'red'}
/>
<Scorecard
label="Accuracy"
value={formatPercent(accuracy, 1)}
color={accuracy >= 90 && accuracy <= 110 ? 'green' : accuracy >= 75 ? 'amber' : 'red'}
/>
<Scorecard
label="Variance"
value={formatCurrency(variance, true)}
color={variance >= 0 ? 'green' : 'red'}
subtitle={variance >= 0 ? 'Over forecast' : 'Under forecast'}
/>
</div>
{/* Forecast vs Actual line chart */}
<ChartCard title="Forecast Trajectory vs Actual" subtitle="Locked commit values over time compared to actual closed won">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border, #e5e7eb)" />
<XAxis dataKey="date" tick={{ fontSize: 12, fill: 'var(--color-muted, #6b7280)' }} />
<YAxis
tick={{ fontSize: 12, fill: 'var(--color-muted, #6b7280)' }}
tickFormatter={v => formatCurrency(v, true)}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-card-bg, #fff)',
border: '1px solid var(--color-card-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => formatCurrency(value as number, true)}
/>
<Legend />
<Line
type="monotone"
dataKey="forecast"
name="Commit Forecast"
stroke={CHART_COLORS.navy}
strokeWidth={2}
dot={{ r: 4, fill: CHART_COLORS.navy }}
/>
<Line
type="monotone"
dataKey="bestCase"
name="Best Case"
stroke={CHART_COLORS.azure}
strokeWidth={2}
strokeDasharray="5 5"
dot={{ r: 3, fill: CHART_COLORS.azure }}
/>
<ReferenceLine
y={totalActual}
stroke={CHART_COLORS.green}
strokeWidth={2}
label={{ value: `Actual: ${formatCurrency(totalActual, true)}`, position: 'right', fill: CHART_COLORS.green, fontSize: 12 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-muted text-sm">Not enough data points to chart</div>
)}
</ChartCard>
{/* District accuracy table */}
<ChartCard title="District-Level Accuracy" subtitle="Forecast vs actual by district">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 px-3 text-muted font-medium">District</th>
<th className="text-right py-2 px-3 text-muted font-medium">Forecast</th>
<th className="text-right py-2 px-3 text-muted font-medium">Actual</th>
<th className="text-right py-2 px-3 text-muted font-medium">Accuracy</th>
<th className="text-right py-2 px-3 text-muted font-medium">Variance</th>
</tr>
</thead>
<tbody>
{districtAccuracy.map(row => (
<tr key={row.district} className="border-b border-card-border/50 hover:bg-card-bg/80">
<td className="py-2 px-3 font-medium text-foreground">{row.displayName}</td>
<td className="py-2 px-3 text-right text-foreground">{formatCurrency(row.forecast, true)}</td>
<td className="py-2 px-3 text-right text-foreground">{formatCurrency(row.actual, true)}</td>
<td className="py-2 px-3 text-right">
<span className={row.accuracy >= 90 && row.accuracy <= 110 ? 'text-success' : row.accuracy >= 75 ? 'text-warning' : 'text-danger'}>
{formatPercent(row.accuracy, 1)}
</span>
</td>
<td className={`py-2 px-3 text-right ${row.variance >= 0 ? 'text-success' : 'text-danger'}`}>
{formatCurrency(row.variance, true)}
</td>
</tr>
))}
{districtAccuracy.length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-muted">No district data available</td></tr>
)}
</tbody>
</table>
</div>
</ChartCard>
{/* Deal-level comparison */}
<ChartCard title="Deal-Level Comparison" subtitle="Deals from the latest lock vs current state — surprises highlighted">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 px-3 text-muted font-medium">Account</th>
<th className="text-right py-2 px-3 text-muted font-medium">Forecast Amt</th>
<th className="text-left py-2 px-3 text-muted font-medium">Forecast Cat.</th>
<th className="text-left py-2 px-3 text-muted font-medium">Current Stage</th>
<th className="text-right py-2 px-3 text-muted font-medium">Current Amt</th>
<th className="text-left py-2 px-3 text-muted font-medium">Status</th>
</tr>
</thead>
<tbody>
{dealComparison.map(deal => (
<tr
key={deal.Opportunity_ID}
className={`border-b border-card-border/50 ${
deal.status === 'surprise_win' || deal.status === 'surprise_loss'
? 'bg-amber-50/50 dark:bg-amber-900/10'
: 'hover:bg-card-bg/80'
}`}
>
<td className="py-2 px-3 font-medium text-foreground">{deal.Account_Name}</td>
<td className="py-2 px-3 text-right text-foreground">
{deal.forecastAmount > 0 ? formatCurrency(deal.forecastAmount, true) : '—'}
</td>
<td className="py-2 px-3 text-muted">{deal.forecastCategory}</td>
<td className="py-2 px-3 text-foreground">{deal.currentStage}</td>
<td className="py-2 px-3 text-right text-foreground">{formatCurrency(deal.currentAmount, true)}</td>
<td className="py-2 px-3">{statusBadge(deal.status)}</td>
</tr>
))}
{dealComparison.length === 0 && (
<tr><td colSpan={6} className="py-4 text-center text-muted">No deal comparison data available</td></tr>
)}
</tbody>
</table>
</div>
</ChartCard>
</div>
</>
);
}

View File

@@ -0,0 +1,553 @@
'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>
);
}

View File

@@ -0,0 +1,386 @@
'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 {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, ReferenceLine,
} from 'recharts';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface MovementData {
fromDate: string;
toDate: string;
startingPipeline: number;
endingPipeline: number;
newPipeline: number;
closedWon: number;
closedLost: number;
upside: number;
downside: number;
removed: number;
details: DetailRow[];
}
interface DetailRow {
type: string;
Opportunity_ID: string;
Account_Name: string;
amount: number;
}
type SortField = 'type' | 'Account_Name' | 'amount';
type SortDir = 'asc' | 'desc';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const TYPE_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
new: { bg: '#61A60E20', text: '#61A60E' },
upside: { bg: '#0098C720', text: '#0098C7' },
downside: { bg: '#F59E0B20', text: '#D97706' },
closed_won: { bg: '#61A60E20', text: '#23800A' },
closed_lost:{ bg: '#EF444420', text: '#DC2626' },
removed: { bg: '#94A3B820', text: '#64748B' },
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
async function apiFetch<T>(body: Record<string, unknown>): Promise<T> {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
return json.data as T;
}
/* ------------------------------------------------------------------ */
/* Custom waterfall tooltip */
/* ------------------------------------------------------------------ */
function WaterfallTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number; payload: { displayValue: number } }[]; label?: string }) {
if (!active || !payload?.length) return null;
const val = payload[0]?.payload?.displayValue ?? 0;
return (
<div className="bg-card-bg border border-card-border rounded-lg px-3 py-2 shadow-lg text-sm">
<div className="font-medium text-foreground">{label}</div>
<div className="text-muted">{formatCurrency(val)}</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Page Component */
/* ------------------------------------------------------------------ */
export default function PipelineMovementPage() {
const [snapshotDates, setSnapshotDates] = useState<string[]>([]);
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const [movement, setMovement] = useState<MovementData | null>(null);
const [loading, setLoading] = useState(true);
const [capturing, setCapturing] = useState(false);
const [sortField, setSortField] = useState<SortField>('amount');
const [sortDir, setSortDir] = useState<SortDir>('desc');
/* ---- Fetch snapshot dates ---- */
const loadDates = useCallback(async () => {
setLoading(true);
try {
const dates = await apiFetch<string[]>({ action: 'pipeline.snapshot_dates' });
setSnapshotDates(dates);
if (dates.length >= 2) {
setFromDate(dates[1]);
setToDate(dates[0]);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadDates(); }, [loadDates]);
/* ---- Fetch movement data when dates change ---- */
useEffect(() => {
if (!fromDate || !toDate) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const data = await apiFetch<MovementData>({
action: 'pipeline.movement',
fromDate,
toDate,
});
if (!cancelled) setMovement(data);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [fromDate, toDate]);
/* ---- Capture snapshot ---- */
const captureSnapshot = useCallback(async () => {
setCapturing(true);
try {
await apiFetch({ action: 'snapshots.capture', period_type: 'weekly' });
await loadDates();
} finally {
setCapturing(false);
}
}, [loadDates]);
/* ---- Waterfall chart data ---- */
const waterfallData = useMemo(() => {
if (!movement) return [];
const items: { name: string; base: number; value: number; displayValue: number; fill: string }[] = [];
let running = movement.startingPipeline;
items.push({ name: 'Starting', base: 0, value: running, displayValue: running, fill: CHART_COLORS.navy });
// Additions
items.push({ name: '+ New', base: running, value: movement.newPipeline, displayValue: movement.newPipeline, fill: CHART_COLORS.green });
running += movement.newPipeline;
items.push({ name: '+ Upside', base: running, value: movement.upside, displayValue: movement.upside, fill: '#61A60E' });
running += movement.upside;
// Subtractions
items.push({ name: '- Downside', base: running - movement.downside, value: movement.downside, displayValue: -movement.downside, fill: '#F59E0B' });
running -= movement.downside;
items.push({ name: '- Closed Won', base: running - movement.closedWon, value: movement.closedWon, displayValue: -movement.closedWon, fill: '#0098C7' });
running -= movement.closedWon;
items.push({ name: '- Closed Lost', base: running - movement.closedLost, value: movement.closedLost, displayValue: -movement.closedLost, fill: '#EF4444' });
running -= movement.closedLost;
items.push({ name: '- Removed', base: running - movement.removed, value: movement.removed, displayValue: -movement.removed, fill: '#94A3B8' });
running -= movement.removed;
items.push({ name: 'Ending', base: 0, value: movement.endingPipeline, displayValue: movement.endingPipeline, fill: CHART_COLORS.navy });
return items;
}, [movement]);
/* ---- Sorted detail rows ---- */
const sortedDetails = useMemo(() => {
if (!movement?.details) return [];
return [...movement.details].sort((a, b) => {
let cmp = 0;
if (sortField === 'amount') cmp = a.amount - b.amount;
else cmp = (a[sortField] ?? '').localeCompare(b[sortField] ?? '');
return sortDir === 'desc' ? -cmp : cmp;
});
}, [movement, sortField, sortDir]);
const toggleSort = useCallback((field: SortField) => {
setSortField(prev => {
if (prev === field) {
setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
return field;
}
setSortDir('desc');
return field;
});
}, []);
/* ---- Net change ---- */
const netChange = movement ? movement.endingPipeline - movement.startingPipeline : 0;
/* ---- Empty state ---- */
if (!loading && snapshotDates.length === 0) {
return (
<div>
<PageHeader title="Pipeline Movement" />
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-full bg-brand-azure/10 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-brand-azure" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 3v18h18" />
<path d="M7 16l4-8 4 4 4-8" />
</svg>
</div>
<h2 className="text-xl font-bold text-foreground mb-2">No Pipeline Snapshots Yet</h2>
<p className="text-muted text-sm mb-6 max-w-md">
Capture your first pipeline snapshot to start tracking week-over-week movement and trends.
</p>
<button
onClick={captureSnapshot}
disabled={capturing}
className="px-5 py-2.5 rounded-lg bg-brand-azure text-white font-medium text-sm hover:bg-brand-azure/90 disabled:opacity-50 transition-colors"
>
{capturing ? 'Capturing...' : 'Capture First Snapshot'}
</button>
</div>
</div>
);
}
return (
<div>
<PageHeader title="Pipeline Movement" />
{/* ---- Date Range Selector ---- */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2">
<label className="text-xs text-muted font-medium uppercase tracking-wider">From</label>
<select
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className="bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-foreground"
>
{snapshotDates.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted font-medium uppercase tracking-wider">To</label>
<select
value={toDate}
onChange={e => setToDate(e.target.value)}
className="bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-foreground"
>
{snapshotDates.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<button
onClick={captureSnapshot}
disabled={capturing}
className="ml-auto px-4 py-1.5 rounded-lg border border-card-border bg-card-bg text-sm font-medium text-foreground hover:bg-brand-azure/10 disabled:opacity-50 transition-colors"
>
{capturing ? 'Capturing...' : 'Capture Snapshot Now'}
</button>
</div>
{/* ---- Loading ---- */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="w-6 h-6 border-2 border-brand-azure border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* ---- Loaded content ---- */}
{!loading && movement && (
<>
{/* ---- Scorecards ---- */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Scorecard label="Starting Pipeline" value={formatCurrency(movement.startingPipeline, true)} />
<Scorecard label="Ending Pipeline" value={formatCurrency(movement.endingPipeline, true)} />
<Scorecard
label="Net Change"
value={formatCurrency(netChange, true)}
color={netChange >= 0 ? 'green' : 'red'}
/>
<Scorecard label="New Deals Added" value={formatCurrency(movement.newPipeline, true)} />
</div>
{/* ---- Waterfall Chart ---- */}
<ChartCard title="Pipeline Bridge" subtitle={`${fromDate} to ${toDate}`}>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={waterfallData} barCategoryGap="15%">
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={false}
tickLine={false}
tickFormatter={(v: number) => formatCurrency(v, true)}
/>
<Tooltip content={<WaterfallTooltip />} cursor={false} />
<ReferenceLine y={0} stroke="#E2E8F0" />
{/* Invisible base bar */}
<Bar dataKey="base" stackId="waterfall" fill="transparent" isAnimationActive={false} />
{/* Visible value bar */}
<Bar dataKey="value" stackId="waterfall" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, idx) => (
<Cell key={idx} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
{/* ---- Movement Detail Table ---- */}
<ChartCard title="Movement Details" subtitle={`${sortedDetails.length} changes`}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
{([
['type', 'Type'],
['Account_Name', 'Account'],
['amount', 'Amount'],
] as [SortField, string][]).map(([field, label]) => (
<th
key={field}
onClick={() => toggleSort(field)}
className="text-left py-2.5 px-3 text-xs text-muted font-medium uppercase tracking-wider cursor-pointer hover:text-foreground select-none"
>
<span className="inline-flex items-center gap-1">
{label}
{sortField === field && (
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{sortDir === 'asc'
? <polyline points="18 15 12 9 6 15" />
: <polyline points="6 9 12 15 18 9" />
}
</svg>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedDetails.map((row, i) => {
const badge = TYPE_BADGE_COLORS[row.type] ?? { bg: '#94A3B820', text: '#64748B' };
return (
<tr key={`${row.Opportunity_ID}-${i}`} className="border-b border-card-border/50 hover:bg-card-bg/50">
<td className="py-2.5 px-3">
<span
className="inline-block px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: badge.bg, color: badge.text }}
>
{row.type.replace('_', ' ')}
</span>
</td>
<td className="py-2.5 px-3 text-foreground">{row.Account_Name}</td>
<td className="py-2.5 px-3 text-foreground font-medium tabular-nums">{formatCurrency(row.amount)}</td>
</tr>
);
})}
{sortedDetails.length === 0 && (
<tr>
<td colSpan={3} className="py-8 text-center text-muted text-sm">No movement details for this period.</td>
</tr>
)}
</tbody>
</table>
</div>
</ChartCard>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,405 @@
'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, formatPercent, formatNumber, CHART_COLORS, CHART_PALETTE, DISTRICT_SHORT } from '@/lib/formatters';
import { useData } from '@/lib/data-context';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, Cell,
} from 'recharts';
interface Playbook {
id: string;
play_name: string;
description: string;
steps_json: string;
}
interface PlaybookProgress {
id: string;
Account_Name: string;
playbook_id: string;
current_step: string;
status: string;
started_at: string;
updated_at: string;
}
function parseSteps(playbook: Playbook): string[] {
try {
const parsed = JSON.parse(playbook.steps_json);
if (Array.isArray(parsed)) {
return parsed.map((s: string | { name?: string; title?: string }) =>
typeof s === 'string' ? s : s.name ?? s.title ?? String(s)
);
}
return [];
} catch {
return [];
}
}
function daysBetween(a: string, b: string): number {
const ms = new Date(b).getTime() - new Date(a).getTime();
return Math.max(0, Math.round(ms / (1000 * 60 * 60 * 24)));
}
export default function PlaybookEffectivenessPage() {
const { filtered } = useData();
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [progress, setProgress] = useState<PlaybookProgress[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPlayId, setSelectedPlayId] = useState<string>('');
const [comparePlayId, setComparePlayId] = useState<string>('');
useEffect(() => {
(async () => {
setLoading(true);
try {
const [pbRes, prRes] = await Promise.all([
fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.list' }),
}),
fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.progress.list', Account_Name: '__all__' }),
}),
]);
const pbJson = await pbRes.json();
const prJson = await prRes.json();
const pbs: Playbook[] = pbJson.data ?? [];
setPlaybooks(pbs);
setProgress(prJson.data ?? []);
if (pbs.length > 0) setSelectedPlayId(pbs[0].id);
} catch {
setPlaybooks([]);
setProgress([]);
} finally {
setLoading(false);
}
})();
}, []);
const selectedPlaybook = useMemo(() => playbooks.find(p => p.id === selectedPlayId), [playbooks, selectedPlayId]);
const comparePlaybook = useMemo(() => playbooks.find(p => p.id === comparePlayId), [playbooks, comparePlayId]);
// Build metrics for a given playbook
const getPlayMetrics = useCallback((playId: string) => {
const play = playbooks.find(p => p.id === playId);
if (!play) return null;
const playProgress = progress.filter(p => p.playbook_id === playId);
const accountNames = new Set(playProgress.map(p => p.Account_Name));
const assigned = playProgress.length;
const active = playProgress.filter(p => p.status === 'in_progress').length;
const completed = playProgress.filter(p => p.status === 'completed').length;
// Pipeline from accounts in this play
const playPipeline = filtered.pipeline.filter(
d => accountNames.has(d.Account_Name) || d.Source_Play === play.play_name
);
const pipelineGenerated = playPipeline.reduce((s, d) => s + d.Amount_USD, 0);
const closedWon = playPipeline
.filter(d => d.Stage === '06-Closed Won')
.reduce((s, d) => s + (d.Closed_Amount_USD ?? d.Amount_USD), 0);
const conversionRate = assigned > 0 ? (playPipeline.filter(d => d.Stage === '06-Closed Won').length / assigned) * 100 : 0;
// Average time in play for completed
const completedProgress = playProgress.filter(p => p.status === 'completed');
const avgDays = completedProgress.length > 0
? completedProgress.reduce((s, p) => s + daysBetween(p.started_at, p.updated_at), 0) / completedProgress.length
: 0;
// Step distribution
const steps = parseSteps(play);
const stepCounts: Record<string, number> = {};
steps.forEach(s => { stepCounts[s] = 0; });
playProgress.forEach(p => {
if (p.current_step in stepCounts) {
stepCounts[p.current_step]++;
} else {
stepCounts[p.current_step] = (stepCounts[p.current_step] ?? 0) + 1;
}
});
// District breakdown
const districtMap: Record<string, { assigned: number; completed: number; closedWon: number }> = {};
playProgress.forEach(p => {
const acct = filtered.accounts.find(a => a.Account_Name === p.Account_Name);
const district = acct?.District_Name ?? 'Unknown';
if (!districtMap[district]) districtMap[district] = { assigned: 0, completed: 0, closedWon: 0 };
districtMap[district].assigned++;
if (p.status === 'completed') districtMap[district].completed++;
});
playPipeline.filter(d => d.Stage === '06-Closed Won').forEach(d => {
if (!districtMap[d.District_Name]) districtMap[d.District_Name] = { assigned: 0, completed: 0, closedWon: 0 };
districtMap[d.District_Name].closedWon += (d.Closed_Amount_USD ?? d.Amount_USD);
});
return {
play,
assigned,
active,
completed,
pipelineGenerated,
closedWon,
conversionRate,
avgDays,
stepCounts,
steps,
districtMap,
};
}, [playbooks, progress, filtered]);
const metrics = useMemo(() => selectedPlayId ? getPlayMetrics(selectedPlayId) : null, [selectedPlayId, getPlayMetrics]);
const compareMetrics = useMemo(() => comparePlayId ? getPlayMetrics(comparePlayId) : null, [comparePlayId, getPlayMetrics]);
// Funnel chart data
const funnelData = useMemo(() => {
if (!metrics) return [];
return [
{ name: 'Assigned', value: metrics.assigned, fill: CHART_COLORS.navy },
{ name: 'Active', value: metrics.active, fill: CHART_COLORS.azure },
{ name: 'Completed', value: metrics.completed, fill: CHART_COLORS.aqua },
{ name: 'Pipeline ($)', value: Math.round(metrics.pipelineGenerated / 1000), fill: CHART_COLORS.lightBlue },
{ name: 'Closed Won ($)', value: Math.round(metrics.closedWon / 1000), fill: CHART_COLORS.green },
];
}, [metrics]);
// Step drop-off chart
const stepDropoffData = useMemo(() => {
if (!metrics) return [];
const ordered = metrics.steps.length > 0 ? metrics.steps : Object.keys(metrics.stepCounts);
return ordered.map((step, i) => ({
step: step.length > 20 ? step.slice(0, 18) + '...' : step,
fullStep: step,
count: metrics.stepCounts[step] ?? 0,
fill: CHART_PALETTE[i % CHART_PALETTE.length],
}));
}, [metrics]);
if (loading) {
return (
<>
<PageHeader title="Playbook Effectiveness" />
<div className="flex items-center justify-center h-64 text-muted">Loading playbook data...</div>
</>
);
}
if (playbooks.length === 0) {
return (
<>
<PageHeader title="Playbook Effectiveness" />
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">No Playbooks Found</h2>
<p className="text-muted text-sm max-w-md">
Create playbooks and assign them to accounts to start tracking effectiveness and conversion funnels.
</p>
</div>
</div>
</>
);
}
return (
<>
<PageHeader title="Playbook Effectiveness" />
<div className="space-y-6">
{/* Playbook selector and compare */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-muted">Playbook</label>
<select
className="bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-foreground"
value={selectedPlayId}
onChange={e => setSelectedPlayId(e.target.value)}
>
{playbooks.map(p => (
<option key={p.id} value={p.id}>{p.play_name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-muted">Compare with</label>
<select
className="bg-card-bg border border-card-border rounded-lg px-3 py-1.5 text-sm text-foreground"
value={comparePlayId}
onChange={e => setComparePlayId(e.target.value)}
>
<option value="">None</option>
{playbooks.filter(p => p.id !== selectedPlayId).map(p => (
<option key={p.id} value={p.id}>{p.play_name}</option>
))}
</select>
</div>
</div>
{/* Playbook description */}
{selectedPlaybook?.description && (
<p className="text-sm text-muted italic">{selectedPlaybook.description}</p>
)}
{/* Scorecards */}
{metrics && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Scorecard label="Assigned" value={formatNumber(metrics.assigned)} />
<Scorecard label="Active" value={formatNumber(metrics.active)} color="amber" />
<Scorecard label="Completed" value={formatNumber(metrics.completed)} color="green" />
<Scorecard label="Pipeline Generated" value={formatCurrency(metrics.pipelineGenerated, true)} />
<Scorecard label="Closed Won" value={formatCurrency(metrics.closedWon, true)} color="green" />
<Scorecard
label="Conversion Rate"
value={formatPercent(metrics.conversionRate, 1)}
color={metrics.conversionRate >= 20 ? 'green' : metrics.conversionRate >= 10 ? 'amber' : 'red'}
/>
</div>
)}
{/* Side-by-side comparison scorecards */}
{compareMetrics && (
<ChartCard title={`Comparison: ${compareMetrics.play.play_name}`} subtitle="Side-by-side metrics">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Scorecard label="Assigned" value={formatNumber(compareMetrics.assigned)} />
<Scorecard label="Active" value={formatNumber(compareMetrics.active)} color="amber" />
<Scorecard label="Completed" value={formatNumber(compareMetrics.completed)} color="green" />
<Scorecard label="Pipeline Generated" value={formatCurrency(compareMetrics.pipelineGenerated, true)} />
<Scorecard label="Closed Won" value={formatCurrency(compareMetrics.closedWon, true)} color="green" />
<Scorecard
label="Conversion Rate"
value={formatPercent(compareMetrics.conversionRate, 1)}
color={compareMetrics.conversionRate >= 20 ? 'green' : compareMetrics.conversionRate >= 10 ? 'amber' : 'red'}
/>
</div>
</ChartCard>
)}
{/* Funnel chart + Time in play */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ChartCard title="Conversion Funnel" subtitle="Account progression through play stages" className="lg:col-span-2">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={funnelData} layout="vertical" margin={{ top: 5, right: 30, left: 80, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border, #e5e7eb)" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 12, fill: 'var(--color-muted, #6b7280)' }} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 12, fill: 'var(--color-muted, #6b7280)' }} width={90} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-card-bg, #fff)',
border: '1px solid var(--color-card-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value) => formatNumber(value as number)}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{funnelData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Time in Play" subtitle="Average days to completion">
<div className="flex flex-col items-center justify-center h-[240px]">
<div className="text-5xl font-bold text-foreground">{metrics ? Math.round(metrics.avgDays) : 0}</div>
<div className="text-sm text-muted mt-2">avg days</div>
{metrics && metrics.completed > 0 && (
<div className="text-xs text-muted mt-1">across {metrics.completed} completed plays</div>
)}
{compareMetrics && compareMetrics.completed > 0 && (
<div className="mt-4 pt-4 border-t border-card-border w-full text-center">
<div className="text-2xl font-bold text-foreground">{Math.round(compareMetrics.avgDays)}</div>
<div className="text-xs text-muted mt-1">{compareMetrics.play.play_name}</div>
</div>
)}
</div>
</ChartCard>
</div>
{/* Step drop-off analysis */}
<ChartCard title="Step Drop-Off Analysis" subtitle="Current distribution of accounts across play steps -- identifies where accounts stall">
{stepDropoffData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stepDropoffData} margin={{ top: 10, right: 30, left: 10, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border, #e5e7eb)" />
<XAxis
dataKey="step"
tick={{ fontSize: 11, fill: 'var(--color-muted, #6b7280)' }}
angle={-30}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fontSize: 12, fill: 'var(--color-muted, #6b7280)' }} allowDecimals={false} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--color-card-bg, #fff)',
border: '1px solid var(--color-card-border, #e5e7eb)',
borderRadius: '8px',
fontSize: '12px',
}}
labelFormatter={(_, payload) => payload?.[0]?.payload?.fullStep ?? ''}
formatter={(value) => `${formatNumber(value as number)} Accounts`}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{stepDropoffData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-muted text-sm">No step data available</div>
)}
</ChartCard>
{/* District comparison table */}
<ChartCard title="District Comparison" subtitle="Play effectiveness by district">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 px-3 text-muted font-medium">District</th>
<th className="text-right py-2 px-3 text-muted font-medium">Assigned</th>
<th className="text-right py-2 px-3 text-muted font-medium">Completed</th>
<th className="text-right py-2 px-3 text-muted font-medium">Completion %</th>
<th className="text-right py-2 px-3 text-muted font-medium">Closed Won</th>
</tr>
</thead>
<tbody>
{metrics && Object.entries(metrics.districtMap)
.sort(([, a], [, b]) => b.closedWon - a.closedWon)
.map(([district, data]) => {
const completionRate = data.assigned > 0 ? (data.completed / data.assigned) * 100 : 0;
return (
<tr key={district} className="border-b border-card-border/50 hover:bg-card-bg/80">
<td className="py-2 px-3 font-medium text-foreground">{DISTRICT_SHORT[district] ?? district}</td>
<td className="py-2 px-3 text-right text-foreground">{data.assigned}</td>
<td className="py-2 px-3 text-right text-foreground">{data.completed}</td>
<td className="py-2 px-3 text-right">
<span className={completionRate >= 50 ? 'text-success' : completionRate >= 25 ? 'text-warning' : 'text-danger'}>
{formatPercent(completionRate, 0)}
</span>
</td>
<td className="py-2 px-3 text-right text-foreground">{formatCurrency(data.closedWon, true)}</td>
</tr>
);
})}
{metrics && Object.keys(metrics.districtMap).length === 0 && (
<tr><td colSpan={5} className="py-4 text-center text-muted">No district data available</td></tr>
)}
</tbody>
</table>
</div>
</ChartCard>
</div>
</>
);
}

View File

@@ -0,0 +1,503 @@
'use client';
import { useData } from '@/lib/data-context';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { Scorecard } from '@/components/ui/Scorecard';
import { formatCurrency, formatPercent, CHART_COLORS } from '@/lib/formatters';
import { useMemo, useState, useEffect, useCallback } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
PieChart, Pie,
} from 'recharts';
import { parseISO, differenceInDays, subDays, startOfQuarter } from 'date-fns';
import { PipelineRecord } from '@/types/data';
const CHART_PALETTE = [
CHART_COLORS.navy,
CHART_COLORS.azure,
CHART_COLORS.aqua,
CHART_COLORS.green,
CHART_COLORS.purple,
CHART_COLORS.lightBlue,
CHART_COLORS.darkGreen,
CHART_COLORS.brightBlue,
];
const LOSS_REASON_OPTIONS = [
'Price',
'Competitor',
'No Budget',
'Timing',
'No Decision',
'Technical Fit',
'Champion Left',
'Other',
];
type TimePeriod = 'all' | '90' | '180' | 'quarter';
interface Playbook {
id: string;
play_name: string;
}
const STAGE_ORDER = [
'01-Prospect',
'02-Qualified',
'03-Solution Dev',
'04-Proposal',
'05-Negotiate',
'06-Closed Won',
];
function isClosedWon(deal: PipelineRecord) {
return deal.Stage === '06-Closed Won';
}
function isClosedLost(deal: PipelineRecord) {
return deal.Stage === '07-Closed Lost';
}
function isClosed(deal: PipelineRecord) {
return isClosedWon(deal) || isClosedLost(deal);
}
export default function WinLossPage() {
const { filtered } = useData();
const { pipeline } = filtered;
const [timePeriod, setTimePeriod] = useState<TimePeriod>('all');
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [savingId, setSavingId] = useState<string | null>(null);
const [localReasons, setLocalReasons] = useState<Record<string, string>>({});
// Fetch playbooks
useEffect(() => {
fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'playbooks.list' }),
})
.then((r) => r.json())
.then((res) => {
if (res.data) setPlaybooks(res.data);
})
.catch(() => {});
}, []);
// Filter by time period
const filteredPipeline = useMemo(() => {
if (timePeriod === 'all') return pipeline;
const now = new Date();
let cutoff: Date;
if (timePeriod === '90') cutoff = subDays(now, 90);
else if (timePeriod === '180') cutoff = subDays(now, 180);
else cutoff = startOfQuarter(now);
return pipeline.filter((d) => {
if (!d.Closed_Date) return false;
try {
return parseISO(d.Closed_Date) >= cutoff;
} catch {
return false;
}
});
}, [pipeline, timePeriod]);
// Core metrics
const metrics = useMemo(() => {
const closed = filteredPipeline.filter(isClosed);
const won = closed.filter(isClosedWon);
const lost = closed.filter(isClosedLost);
const totalClosed = closed.length;
const winRate = totalClosed > 0 ? (won.length / totalClosed) * 100 : 0;
const avgDealSizeWon =
won.length > 0
? won.reduce((s, d) => s + (d.Closed_Amount_USD ?? d.Amount_USD), 0) / won.length
: 0;
const avgDaysToClose =
won.length > 0
? won.reduce((s, d) => {
if (!d.Closed_Date || !d.Created_Date) return s;
try {
return s + differenceInDays(parseISO(d.Closed_Date), parseISO(d.Created_Date));
} catch {
return s;
}
}, 0) / won.length
: 0;
return { totalClosed, winRate, avgDealSizeWon, avgDaysToClose, won, lost, closed };
}, [filteredPipeline]);
// Win Rate by Stage funnel
const stageFunnel = useMemo(() => {
const transitions: { label: string; rate: number; won: number; total: number }[] = [];
for (let i = 0; i < STAGE_ORDER.length - 1; i++) {
const fromStage = STAGE_ORDER[i];
const toStage = STAGE_ORDER[i + 1];
// Deals that reached fromStage = all deals at that stage or beyond
const reachedFrom = filteredPipeline.filter((d) => {
const idx = STAGE_ORDER.indexOf(d.Stage);
if (idx >= i) return true;
// Closed lost deals - check if they were at or past this stage
if (isClosedLost(d)) {
// Use Stage_Entered_Date or assume they passed through earlier stages
return i <= 4; // Lost deals passed through stages before closing
}
return false;
});
const reachedTo = filteredPipeline.filter((d) => {
const idx = STAGE_ORDER.indexOf(d.Stage);
if (idx >= i + 1) return true;
if (isClosedLost(d)) return false;
if (d.Stage === '07-Closed Lost') return false;
return false;
});
const total = reachedFrom.length;
const advanced = reachedTo.length;
const rate = total > 0 ? (advanced / total) * 100 : 0;
const shortFrom = fromStage.split('-')[0];
const shortTo = toStage.split('-')[0];
transitions.push({ label: `${shortFrom}${shortTo}`, rate, won: advanced, total });
}
return transitions;
}, [filteredPipeline]);
// Win Rate by Competitor
const competitorData = useMemo(() => {
const closed = filteredPipeline.filter(isClosed);
const byComp: Record<string, { wins: number; total: number }> = {};
closed.forEach((d) => {
const comp = d.Competitor || 'No Competitor';
if (!byComp[comp]) byComp[comp] = { wins: 0, total: 0 };
byComp[comp].total++;
if (isClosedWon(d)) byComp[comp].wins++;
});
return Object.entries(byComp)
.map(([name, { wins, total }]) => ({
name,
winRate: total > 0 ? (wins / total) * 100 : 0,
wins,
losses: total - wins,
total,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 10);
}, [filteredPipeline]);
// Win Rate by Deal Size Band
const dealSizeData = useMemo(() => {
const bands = [
{ label: '<$50K', min: 0, max: 50000 },
{ label: '$50-200K', min: 50000, max: 200000 },
{ label: '$200-500K', min: 200000, max: 500000 },
{ label: '$500K+', min: 500000, max: Infinity },
];
const closed = filteredPipeline.filter(isClosed);
return bands.map((band) => {
const inBand = closed.filter((d) => d.Amount_USD >= band.min && d.Amount_USD < band.max);
const wins = inBand.filter(isClosedWon).length;
return {
name: band.label,
winRate: inBand.length > 0 ? (wins / inBand.length) * 100 : 0,
wins,
total: inBand.length,
};
});
}, [filteredPipeline]);
// Win Rate by Source Play
const sourcePlayData = useMemo(() => {
const closed = filteredPipeline.filter(isClosed);
const byPlay: Record<string, { wins: number; total: number }> = {};
closed.forEach((d) => {
const play = d.Source_Play || 'None';
if (!byPlay[play]) byPlay[play] = { wins: 0, total: 0 };
byPlay[play].total++;
if (isClosedWon(d)) byPlay[play].wins++;
});
// Join with playbook names
const playbookMap = new Map(playbooks.map((p) => [p.id, p.play_name]));
return Object.entries(byPlay)
.map(([id, { wins, total }]) => ({
name: playbookMap.get(id) || id,
winRate: total > 0 ? (wins / total) * 100 : 0,
wins,
total,
}))
.sort((a, b) => b.total - a.total);
}, [filteredPipeline, playbooks]);
// Loss Reason breakdown
const lossReasonData = useMemo(() => {
const lost = filteredPipeline.filter(isClosedLost);
const counts: Record<string, number> = {};
lost.forEach((d) => {
const reason = localReasons[d.Opportunity_ID] || d.Loss_Reason;
if (reason) {
counts[reason] = (counts[reason] || 0) + 1;
}
});
return Object.entries(counts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}, [filteredPipeline, localReasons]);
// Deals needing close-out (no loss reason)
const closeOutDeals = useMemo(() => {
return filteredPipeline.filter(
(d) => isClosedLost(d) && !d.Loss_Reason && !localReasons[d.Opportunity_ID]
);
}, [filteredPipeline, localReasons]);
const handleSaveLossReason = useCallback(async (oppId: string, reason: string) => {
setSavingId(oppId);
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'pipeline.loss_reason', Opportunity_ID: oppId, Loss_Reason: reason }),
});
setLocalReasons((prev) => ({ ...prev, [oppId]: reason }));
} catch {
// silently fail
} finally {
setSavingId(null);
}
}, []);
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-card-bg border border-card-border rounded-lg px-3 py-2 shadow-lg text-xs">
<div className="font-semibold text-foreground mb-1">{label}</div>
{payload.map((p: any, i: number) => (
<div key={i} className="text-muted">
{p.name}: {typeof p.value === 'number' ? formatPercent(p.value) : p.value}
</div>
))}
</div>
);
};
return (
<div className="space-y-6">
<PageHeader title="Win/Loss Analysis" />
{/* Time Period Filter */}
<div className="flex gap-2">
{([
['all', 'All Time'],
['90', 'Last 90 Days'],
['180', 'Last 180 Days'],
['quarter', 'This Quarter'],
] as [TimePeriod, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => setTimePeriod(key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
timePeriod === key
? 'bg-[#1B1D36] text-white'
: 'bg-card-bg border border-card-border text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{/* Summary Scorecards */}
<div className="grid grid-cols-4 gap-4">
<Scorecard label="Total Closed" value={metrics.totalClosed} />
<Scorecard label="Win Rate" value={formatPercent(metrics.winRate)} />
<Scorecard label="Avg Deal Size (Won)" value={formatCurrency(metrics.avgDealSizeWon, true)} />
<Scorecard label="Avg Days to Close" value={Math.round(metrics.avgDaysToClose)} />
</div>
{/* Row 1: Stage Funnel + Competitor */}
<div className="grid grid-cols-2 gap-6">
{/* Win Rate by Stage Funnel */}
<ChartCard title="Win Rate by Stage" subtitle="Conversion rate at each stage transition">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stageFunnel} layout="vertical" margin={{ left: 20, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis type="category" dataKey="label" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} width={70} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="rate" name="Win Rate" radius={[0, 4, 4, 0]}>
{stageFunnel.map((_, i) => (
<Cell key={i} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
{/* Win Rate by Competitor */}
<ChartCard title="Win Rate by Competitor" subtitle="Performance against key competitors">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={competitorData} layout="vertical" margin={{ left: 20, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} width={100} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="winRate" name="Win Rate" fill={CHART_COLORS.azure} radius={[0, 4, 4, 0]}>
{competitorData.map((entry) => (
<Cell
key={entry.name}
fill={entry.winRate >= 50 ? CHART_COLORS.green : CHART_COLORS.navy}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
{/* Row 2: Deal Size Band + Source Play */}
<div className="grid grid-cols-2 gap-6">
{/* Win Rate by Deal Size */}
<ChartCard title="Win Rate by Deal Size" subtitle="Win rate across deal size bands">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={dealSizeData} margin={{ left: 0, right: 20, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="winRate" name="Win Rate" radius={[4, 4, 0, 0]}>
{dealSizeData.map((entry, i) => (
<Cell key={entry.name} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
{/* Win Rate by Source Play */}
<ChartCard title="Win Rate by Source Play" subtitle="Which playbooks generate the best outcomes">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={sourcePlayData} layout="vertical" margin={{ left: 20, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} width={120} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="winRate" name="Win Rate" fill={CHART_COLORS.azure} radius={[0, 4, 4, 0]}>
{sourcePlayData.map((entry) => (
<Cell
key={entry.name}
fill={entry.winRate >= 50 ? CHART_COLORS.green : CHART_COLORS.azure}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
{/* Loss Reason Analysis */}
<div className="grid grid-cols-2 gap-6">
<ChartCard title="Loss Reason Analysis" subtitle="Breakdown of why deals were lost">
<div className="h-72 flex items-center justify-center">
{lossReasonData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={lossReasonData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
paddingAngle={2}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
label={((props: any) => `${props.name} (${formatPercent((props.percent ?? 0) * 100)})`) as any}
labelLine={{ stroke: 'var(--color-muted)' }}
>
{lossReasonData.map((_, i) => (
<Cell key={i} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0];
return (
<div className="bg-card-bg border border-card-border rounded-lg px-3 py-2 shadow-lg text-xs">
<div className="font-semibold text-foreground">{d.name}</div>
<div className="text-muted">{d.value} deal{Number(d.value) !== 1 ? 's' : ''}</div>
</div>
);
}}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-sm text-muted">No loss reason data available</div>
)}
</div>
</ChartCard>
{/* Close-Out Section */}
<ChartCard
title="Close-Out: Missing Loss Reasons"
subtitle={`${closeOutDeals.length} closed-lost deal${closeOutDeals.length !== 1 ? 's' : ''} need a loss reason`}
>
<div className="max-h-72 overflow-y-auto">
{closeOutDeals.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 font-medium text-muted">Account</th>
<th className="text-right py-2 font-medium text-muted">Amount</th>
<th className="text-left py-2 pl-3 font-medium text-muted">Loss Reason</th>
</tr>
</thead>
<tbody>
{closeOutDeals.map((deal) => (
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50">
<td className="py-2 text-foreground">{deal.Account_Name}</td>
<td className="py-2 text-right text-foreground">
{formatCurrency(deal.Amount_USD, true)}
</td>
<td className="py-2 pl-3">
<select
className="w-full text-xs bg-card-bg border border-card-border rounded px-2 py-1 text-foreground"
defaultValue=""
disabled={savingId === deal.Opportunity_ID}
onChange={(e) => {
if (e.target.value) {
handleSaveLossReason(deal.Opportunity_ID, e.target.value);
}
}}
>
<option value="" disabled>
{savingId === deal.Opportunity_ID ? 'Saving...' : 'Select...'}
</option>
{LOSS_REASON_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-sm text-muted text-center py-8">
All closed-lost deals have a loss reason assigned
</div>
)}
</div>
</ChartCard>
</div>
</div>
);
}

View File

@@ -8,6 +8,9 @@ import {
getDistrictTargets, upsertDistrictTarget,
getDataQualityReport,
getDashboardData,
getForecastLocks, lockForecast, getForecastOverrides, upsertForecastOverride, deleteForecastOverride,
getPipelineMovement, getSnapshotDates,
updateLossReason,
} from '@/lib/db';
export async function POST(req: NextRequest) {
@@ -68,6 +71,31 @@ export async function POST(req: NextRequest) {
case 'quality.report':
return NextResponse.json({ data: getDataQualityReport() });
// Forecast
case 'forecast.locks':
return NextResponse.json({ data: getForecastLocks(body.quarter) });
case 'forecast.lock':
return NextResponse.json({ data: lockForecast(body.quarter, body.locked_by, body.notes) });
case 'forecast.overrides':
return NextResponse.json({ data: getForecastOverrides() });
case 'forecast.override':
upsertForecastOverride(body);
return NextResponse.json({ success: true });
case 'forecast.override.delete':
deleteForecastOverride(body.Opportunity_ID);
return NextResponse.json({ success: true });
// Pipeline Movement
case 'pipeline.movement':
return NextResponse.json({ data: getPipelineMovement(body.fromDate, body.toDate) });
case 'pipeline.snapshot_dates':
return NextResponse.json({ data: getSnapshotDates() });
// Win/Loss
case 'pipeline.loss_reason':
updateLossReason(body.Opportunity_ID, body.Loss_Reason);
return NextResponse.json({ success: true });
// Export
case 'export': {
const data = getDashboardData();