From d79794b164bb36f4eb9f2cdbcc7def0c5d4d96b3 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Tue, 1 Sep 2026 13:49:05 -0400 Subject: [PATCH] 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 --- src/app/(dashboard)/accounts/page.tsx | 49 ++ .../analytics/forecast-accuracy/page.tsx | 457 +++++++++++++++ .../(dashboard)/analytics/forecast/page.tsx | 553 ++++++++++++++++++ .../analytics/pipeline-movement/page.tsx | 386 ++++++++++++ .../analytics/playbook-effectiveness/page.tsx | 405 +++++++++++++ .../(dashboard)/analytics/win-loss/page.tsx | 503 ++++++++++++++++ src/app/api/phase2/route.ts | 28 + src/components/layout/Sidebar.tsx | 88 ++- src/lib/db.ts | 180 +++++- src/lib/mock-data.ts | 1 + src/types/data.ts | 26 + 11 files changed, 2646 insertions(+), 30 deletions(-) create mode 100644 src/app/(dashboard)/analytics/forecast-accuracy/page.tsx create mode 100644 src/app/(dashboard)/analytics/forecast/page.tsx create mode 100644 src/app/(dashboard)/analytics/pipeline-movement/page.tsx create mode 100644 src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx create mode 100644 src/app/(dashboard)/analytics/win-loss/page.tsx diff --git a/src/app/(dashboard)/accounts/page.tsx b/src/app/(dashboard)/accounts/page.tsx index d2f23a5..19c63bd 100644 --- a/src/app/(dashboard)/accounts/page.tsx +++ b/src/app/(dashboard)/accounts/page.tsx @@ -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 ( + + + + ); +} + +const TREND_STYLES: Record = { + 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 (
+
+ + + {TREND_STYLES[trend.direction].label} + + 12wk +
ARR
diff --git a/src/app/(dashboard)/analytics/forecast-accuracy/page.tsx b/src/app/(dashboard)/analytics/forecast-accuracy/page.tsx new file mode 100644 index 0000000..78d836f --- /dev/null +++ b/src/app/(dashboard)/analytics/forecast-accuracy/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [quarter, setQuarter] = useState(''); + const [availableQuarters, setAvailableQuarters] = useState([]); + + 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 = {}; + 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 = {}; + 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(filtered.pipeline.map(p => [p.Opportunity_ID, p])); + const lockDealIds = new Set(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 = { + 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 = { + won: 'Won', + lost: 'Lost', + open: 'Open', + surprise_win: 'Surprise Win', + surprise_loss: 'Surprise Miss', + }; + return ( + + {labels[status] ?? status} + + ); + }; + + if (loading) { + return ( + <> + +
Loading forecast data...
+ + ); + } + + if (locks.length === 0 && !loading) { + return ( + <> + +
+
+

No Forecast Locks Found

+

+ 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. +

+
+ + Go to Forecast Workspace + + + + +
+ + ); + } + + return ( + <> + +
+ {/* Quarter selector */} +
+ + +
+ + {/* Scorecards */} +
+ + = forecastAmount ? 'green' : 'red'} + /> + = 90 && accuracy <= 110 ? 'green' : accuracy >= 75 ? 'amber' : 'red'} + /> + = 0 ? 'green' : 'red'} + subtitle={variance >= 0 ? 'Over forecast' : 'Under forecast'} + /> +
+ + {/* Forecast vs Actual line chart */} + + {chartData.length > 0 ? ( + + + + + formatCurrency(v, true)} + /> + formatCurrency(value as number, true)} + /> + + + + + + + ) : ( +
Not enough data points to chart
+ )} +
+ + {/* District accuracy table */} + +
+ + + + + + + + + + + + {districtAccuracy.map(row => ( + + + + + + + + ))} + {districtAccuracy.length === 0 && ( + + )} + +
DistrictForecastActualAccuracyVariance
{row.displayName}{formatCurrency(row.forecast, true)}{formatCurrency(row.actual, true)} + = 90 && row.accuracy <= 110 ? 'text-success' : row.accuracy >= 75 ? 'text-warning' : 'text-danger'}> + {formatPercent(row.accuracy, 1)} + + = 0 ? 'text-success' : 'text-danger'}`}> + {formatCurrency(row.variance, true)} +
No district data available
+
+
+ + {/* Deal-level comparison */} + +
+ + + + + + + + + + + + + {dealComparison.map(deal => ( + + + + + + + + + ))} + {dealComparison.length === 0 && ( + + )} + +
AccountForecast AmtForecast Cat.Current StageCurrent AmtStatus
{deal.Account_Name} + {deal.forecastAmount > 0 ? formatCurrency(deal.forecastAmount, true) : '—'} + {deal.forecastCategory}{deal.currentStage}{formatCurrency(deal.currentAmount, true)}{statusBadge(deal.status)}
No deal comparison data available
+
+
+
+ + ); +} diff --git a/src/app/(dashboard)/analytics/forecast/page.tsx b/src/app/(dashboard)/analytics/forecast/page.tsx new file mode 100644 index 0000000..e790cb9 --- /dev/null +++ b/src/app/(dashboard)/analytics/forecast/page.tsx @@ -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 = { 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(action: string, body: Record = {}): Promise { + 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 ( +
+
+

Lock Forecast

+

{message}

+ 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" + /> +
+ + +
+
+
+ ); +} + +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 ( + + ); + } + + const commit = () => { + if (draft !== value) onSave(draft); + setEditing(false); + }; + + if (type === 'select') { + return ( + + ); + } + + return ( + 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([]); + const [locks, setLocks] = useState([]); + 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(); + 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 = { 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 = {}; + 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 ( +
+ + + {/* Quarter selector & Lock button */} +
+
+ + +
+ + +
+ + {/* Scorecards */} +
+ + + + = 1 ? 'green' : 'red'} + /> +
+ + {/* Loading indicator */} + {loading && ( +
Loading forecast data...
+ )} + + {/* Deal table grouped by category */} + +
+ {(['Commit', 'Best Case', 'Pipeline'] as const).map((cat) => ( +
+
+ +

{cat}

+ + ({grouped[cat]?.length ?? 0} deals ·{' '} + {formatCurrency(grouped[cat]?.reduce((s, d) => s + d.Amount_USD, 0) ?? 0, true)}) + +
+ +
+ + + + + + + + + + + + + {(grouped[cat] ?? []).map((deal) => ( + + + + + + + + + ))} + {(grouped[cat] ?? []).length === 0 && ( + + + + )} + +
AccountOpportunityStageAmountCategoryClose Date
+ {deal.Account_Name} + {deal._overridden && ( + + edited + + )} + {deal.Opportunity_ID}{deal.Stage} + handleOverride(deal, 'Amount_USD', v)} + /> + + handleOverride(deal, 'Forecast_Category', v)} + /> + + handleOverride(deal, 'Expected_Close_Date', v)} + /> +
No deals
+
+
+ ))} +
+
+ + {/* Stacked bar chart by district */} + +
+ + + + + formatCurrency(v, true)} + tick={{ fontSize: 11, fill: 'var(--color-muted)' }} + /> + formatCurrency(value as number)} + contentStyle={{ + backgroundColor: 'var(--color-card-bg)', + border: '1px solid var(--color-card-border)', + borderRadius: '8px', + fontSize: '12px', + }} + /> + + + + + + +
+
+ + {/* Forecast History */} + + {locks.length === 0 && !loading ? ( +

No forecast locks recorded yet for {quarter}.

+ ) : ( +
+ + + + + + + + + + + + + + {locks.map((lock) => { + const commitDelta = totals.commit - lock.total_commit; + return ( + + + + + + + + + + ); + })} + +
Lock DateLocked ByCommitBest CasePipelineNotesvs Current
+ {new Date(lock.lock_date).toLocaleDateString()} + {lock.locked_by}{formatCurrency(lock.total_commit, true)}{formatCurrency(lock.total_best_case, true)}{formatCurrency(lock.total_pipeline, true)}{lock.notes || '—'} + = 0 ? 'text-success' : 'text-danger'}> + {commitDelta >= 0 ? '+' : ''} + {formatCurrency(commitDelta, true)} + +
+
+ )} +
+ + {/* Lock Confirm Dialog */} + { setLockDialogOpen(false); setLockNotes(''); }} + confirming={locking} + /> +
+ ); +} diff --git a/src/app/(dashboard)/analytics/pipeline-movement/page.tsx b/src/app/(dashboard)/analytics/pipeline-movement/page.tsx new file mode 100644 index 0000000..94cfa24 --- /dev/null +++ b/src/app/(dashboard)/analytics/pipeline-movement/page.tsx @@ -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 = { + 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(body: Record): Promise { + 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 ( +
+
{label}
+
{formatCurrency(val)}
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Page Component */ +/* ------------------------------------------------------------------ */ + +export default function PipelineMovementPage() { + const [snapshotDates, setSnapshotDates] = useState([]); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [movement, setMovement] = useState(null); + const [loading, setLoading] = useState(true); + const [capturing, setCapturing] = useState(false); + const [sortField, setSortField] = useState('amount'); + const [sortDir, setSortDir] = useState('desc'); + + /* ---- Fetch snapshot dates ---- */ + const loadDates = useCallback(async () => { + setLoading(true); + try { + const dates = await apiFetch({ 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({ + 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 ( +
+ +
+
+ + + + +
+

No Pipeline Snapshots Yet

+

+ Capture your first pipeline snapshot to start tracking week-over-week movement and trends. +

+ +
+
+ ); + } + + return ( +
+ + + {/* ---- Date Range Selector ---- */} +
+
+ + +
+
+ + +
+ +
+ + {/* ---- Loading ---- */} + {loading && ( +
+
+
+ )} + + {/* ---- Loaded content ---- */} + {!loading && movement && ( + <> + {/* ---- Scorecards ---- */} +
+ + + = 0 ? 'green' : 'red'} + /> + +
+ + {/* ---- Waterfall Chart ---- */} + +
+ + + + formatCurrency(v, true)} + /> + } cursor={false} /> + + {/* Invisible base bar */} + + {/* Visible value bar */} + + {waterfallData.map((entry, idx) => ( + + ))} + + + +
+
+ + {/* ---- Movement Detail Table ---- */} + +
+ + + + {([ + ['type', 'Type'], + ['Account_Name', 'Account'], + ['amount', 'Amount'], + ] as [SortField, string][]).map(([field, label]) => ( + + ))} + + + + {sortedDetails.map((row, i) => { + const badge = TYPE_BADGE_COLORS[row.type] ?? { bg: '#94A3B820', text: '#64748B' }; + return ( + + + + + + ); + })} + {sortedDetails.length === 0 && ( + + + + )} + +
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" + > + + {label} + {sortField === field && ( + + {sortDir === 'asc' + ? + : + } + + )} + +
+ + {row.type.replace('_', ' ')} + + {row.Account_Name}{formatCurrency(row.amount)}
No movement details for this period.
+
+
+ + )} +
+ ); +} diff --git a/src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx b/src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx new file mode 100644 index 0000000..1a8d9c2 --- /dev/null +++ b/src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx @@ -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([]); + const [progress, setProgress] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedPlayId, setSelectedPlayId] = useState(''); + const [comparePlayId, setComparePlayId] = useState(''); + + 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 = {}; + 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 = {}; + 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 ( + <> + +
Loading playbook data...
+ + ); + } + + if (playbooks.length === 0) { + return ( + <> + +
+
+

No Playbooks Found

+

+ Create playbooks and assign them to accounts to start tracking effectiveness and conversion funnels. +

+
+
+ + ); + } + + return ( + <> + +
+ {/* Playbook selector and compare */} +
+
+ + +
+
+ + +
+
+ + {/* Playbook description */} + {selectedPlaybook?.description && ( +

{selectedPlaybook.description}

+ )} + + {/* Scorecards */} + {metrics && ( +
+ + + + + + = 20 ? 'green' : metrics.conversionRate >= 10 ? 'amber' : 'red'} + /> +
+ )} + + {/* Side-by-side comparison scorecards */} + {compareMetrics && ( + +
+ + + + + + = 20 ? 'green' : compareMetrics.conversionRate >= 10 ? 'amber' : 'red'} + /> +
+
+ )} + + {/* Funnel chart + Time in play */} +
+ + + + + + + formatNumber(value as number)} + /> + + {funnelData.map((entry, i) => ( + + ))} + + + + + + +
+
{metrics ? Math.round(metrics.avgDays) : 0}
+
avg days
+ {metrics && metrics.completed > 0 && ( +
across {metrics.completed} completed plays
+ )} + {compareMetrics && compareMetrics.completed > 0 && ( +
+
{Math.round(compareMetrics.avgDays)}
+
{compareMetrics.play.play_name}
+
+ )} +
+
+
+ + {/* Step drop-off analysis */} + + {stepDropoffData.length > 0 ? ( + + + + + + payload?.[0]?.payload?.fullStep ?? ''} + formatter={(value) => `${formatNumber(value as number)} Accounts`} + /> + + {stepDropoffData.map((entry, i) => ( + + ))} + + + + ) : ( +
No step data available
+ )} +
+ + {/* District comparison table */} + +
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + {metrics && Object.keys(metrics.districtMap).length === 0 && ( + + )} + +
DistrictAssignedCompletedCompletion %Closed Won
{DISTRICT_SHORT[district] ?? district}{data.assigned}{data.completed} + = 50 ? 'text-success' : completionRate >= 25 ? 'text-warning' : 'text-danger'}> + {formatPercent(completionRate, 0)} + + {formatCurrency(data.closedWon, true)}
No district data available
+
+
+
+ + ); +} diff --git a/src/app/(dashboard)/analytics/win-loss/page.tsx b/src/app/(dashboard)/analytics/win-loss/page.tsx new file mode 100644 index 0000000..4f39fa5 --- /dev/null +++ b/src/app/(dashboard)/analytics/win-loss/page.tsx @@ -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('all'); + const [playbooks, setPlaybooks] = useState([]); + const [savingId, setSavingId] = useState(null); + const [localReasons, setLocalReasons] = useState>({}); + + // 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 = {}; + 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 = {}; + 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 = {}; + 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 ( +
+
{label}
+ {payload.map((p: any, i: number) => ( +
+ {p.name}: {typeof p.value === 'number' ? formatPercent(p.value) : p.value} +
+ ))} +
+ ); + }; + + return ( +
+ + + {/* Time Period Filter */} +
+ {([ + ['all', 'All Time'], + ['90', 'Last 90 Days'], + ['180', 'Last 180 Days'], + ['quarter', 'This Quarter'], + ] as [TimePeriod, string][]).map(([key, label]) => ( + + ))} +
+ + {/* Summary Scorecards */} +
+ + + + +
+ + {/* Row 1: Stage Funnel + Competitor */} +
+ {/* Win Rate by Stage Funnel */} + +
+ + + `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} /> + + } /> + + {stageFunnel.map((_, i) => ( + + ))} + + + +
+
+ + {/* Win Rate by Competitor */} + +
+ + + `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} /> + + } /> + + {competitorData.map((entry) => ( + = 50 ? CHART_COLORS.green : CHART_COLORS.navy} + /> + ))} + + + +
+
+
+ + {/* Row 2: Deal Size Band + Source Play */} +
+ {/* Win Rate by Deal Size */} + +
+ + + + `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} /> + } /> + + {dealSizeData.map((entry, i) => ( + + ))} + + + +
+
+ + {/* Win Rate by Source Play */} + +
+ + + `${v}%`} tick={{ fontSize: 11, fill: 'var(--color-muted)' }} /> + + } /> + + {sourcePlayData.map((entry) => ( + = 50 ? CHART_COLORS.green : CHART_COLORS.azure} + /> + ))} + + + +
+
+
+ + {/* Loss Reason Analysis */} +
+ +
+ {lossReasonData.length > 0 ? ( + + + `${props.name} (${formatPercent((props.percent ?? 0) * 100)})`) as any} + labelLine={{ stroke: 'var(--color-muted)' }} + > + {lossReasonData.map((_, i) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const d = payload[0]; + return ( +
+
{d.name}
+
{d.value} deal{Number(d.value) !== 1 ? 's' : ''}
+
+ ); + }} + /> +
+
+ ) : ( +
No loss reason data available
+ )} +
+
+ + {/* Close-Out Section */} + +
+ {closeOutDeals.length > 0 ? ( + + + + + + + + + + {closeOutDeals.map((deal) => ( + + + + + + ))} + +
AccountAmountLoss Reason
{deal.Account_Name} + {formatCurrency(deal.Amount_USD, true)} + + +
+ ) : ( +
+ All closed-lost deals have a loss reason assigned +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/api/phase2/route.ts b/src/app/api/phase2/route.ts index 1700d50..7010140 100644 --- a/src/app/api/phase2/route.ts +++ b/src/app/api/phase2/route.ts @@ -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(); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e57b221..de1047f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,18 +4,6 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useState } from 'react'; -const navItems = [ - { href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' }, - { href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' }, - { href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' }, - { href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' }, - { href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' }, - { href: '/goals', label: 'Goals & Targets', icon: GoalsIcon, shortLabel: 'Goals' }, - { href: '/playbooks', label: 'Playbooks', icon: PlaybookIcon, shortLabel: 'Plays' }, - { href: '/reports/status', label: 'Weekly Report', icon: ReportsIcon, shortLabel: 'Report' }, - { href: '/reports/executive', label: 'Exec Summary', icon: ExecIcon, shortLabel: 'Summary' }, -]; - function DashboardIcon({ className }: { className?: string }) { return ( @@ -80,6 +68,14 @@ function ExecIcon({ className }: { className?: string }) { ); } +function AnalyticsIcon({ className }: { className?: string }) { + return ( + + + + ); +} + function AccountsIcon({ className }: { className?: string }) { return ( @@ -88,9 +84,31 @@ function AccountsIcon({ className }: { className?: string }) { ); } +const navItems = [ + { href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' }, + { href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' }, + { href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' }, + { href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' }, + { href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' }, + { href: '/goals', label: 'Goals & Targets', icon: GoalsIcon, shortLabel: 'Goals' }, + { href: '/playbooks', label: 'Playbooks', icon: PlaybookIcon, shortLabel: 'Plays' }, + { href: '/reports/status', label: 'Weekly Report', icon: ReportsIcon, shortLabel: 'Report' }, + { href: '/reports/executive', label: 'Exec Summary', icon: ExecIcon, shortLabel: 'Summary' }, +]; + +const analyticsItems = [ + { href: '/analytics/pipeline-movement', label: 'Pipeline Movement' }, + { href: '/analytics/forecast', label: 'Forecast Workspace' }, + { href: '/analytics/forecast-accuracy', label: 'Forecast Accuracy' }, + { href: '/analytics/win-loss', label: 'Win/Loss Analysis' }, + { href: '/analytics/playbook-effectiveness', label: 'Playbook Effectiveness' }, +]; + export function Sidebar() { const pathname = usePathname(); const [collapsed, setCollapsed] = useState(false); + const [analyticsOpen, setAnalyticsOpen] = useState(pathname.startsWith('/analytics')); + const isAnalyticsActive = pathname.startsWith('/analytics'); return ( <> @@ -116,7 +134,7 @@ export function Sidebar() {
-