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:
@@ -100,6 +100,46 @@ function getSuggestedPriority(account: AccountRecord): { level: string; reason:
|
|||||||
return { level: 'Low', reason: 'No compelling near-term event' };
|
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() {
|
export default function AccountExplorer() {
|
||||||
const { filtered, refresh } = useData();
|
const { filtered, refresh } = useData();
|
||||||
const { accounts, pipeline, activities, targets } = filtered;
|
const { accounts, pipeline, activities, targets } = filtered;
|
||||||
@@ -959,6 +999,8 @@ export default function AccountExplorer() {
|
|||||||
const suggestion = getSuggestedPriority(account);
|
const suggestion = getSuggestedPriority(account);
|
||||||
const mismatch = suggestion.level !== account.Priority;
|
const mismatch = suggestion.level !== account.Priority;
|
||||||
const health = scoreAccountHealth(account, activities, pipeline);
|
const health = scoreAccountHealth(account, activities, pipeline);
|
||||||
|
const acctActs = activities.filter(a => a.Account_Name === account.Account_Name);
|
||||||
|
const trend = getEngagementTrend(acctActs);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.Account_Name}
|
key={account.Account_Name}
|
||||||
@@ -1011,6 +1053,13 @@ export default function AccountExplorer() {
|
|||||||
{health.health_level} ({health.health_score})
|
{health.health_level} ({health.health_score})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted">ARR</div>
|
<div className="text-[10px] text-muted">ARR</div>
|
||||||
|
|||||||
457
src/app/(dashboard)/analytics/forecast-accuracy/page.tsx
Normal file
457
src/app/(dashboard)/analytics/forecast-accuracy/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
553
src/app/(dashboard)/analytics/forecast/page.tsx
Normal file
553
src/app/(dashboard)/analytics/forecast/page.tsx
Normal 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 ·{' '}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
src/app/(dashboard)/analytics/pipeline-movement/page.tsx
Normal file
386
src/app/(dashboard)/analytics/pipeline-movement/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx
Normal file
405
src/app/(dashboard)/analytics/playbook-effectiveness/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
src/app/(dashboard)/analytics/win-loss/page.tsx
Normal file
503
src/app/(dashboard)/analytics/win-loss/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
getDistrictTargets, upsertDistrictTarget,
|
getDistrictTargets, upsertDistrictTarget,
|
||||||
getDataQualityReport,
|
getDataQualityReport,
|
||||||
getDashboardData,
|
getDashboardData,
|
||||||
|
getForecastLocks, lockForecast, getForecastOverrides, upsertForecastOverride, deleteForecastOverride,
|
||||||
|
getPipelineMovement, getSnapshotDates,
|
||||||
|
updateLossReason,
|
||||||
} from '@/lib/db';
|
} from '@/lib/db';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -68,6 +71,31 @@ export async function POST(req: NextRequest) {
|
|||||||
case 'quality.report':
|
case 'quality.report':
|
||||||
return NextResponse.json({ data: getDataQualityReport() });
|
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
|
// Export
|
||||||
case 'export': {
|
case 'export': {
|
||||||
const data = getDashboardData();
|
const data = getDashboardData();
|
||||||
|
|||||||
@@ -4,18 +4,6 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
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 }) {
|
function DashboardIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -80,6 +68,14 @@ function ExecIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnalyticsIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><polyline points="7.5 4.21 12 6.81 16.5 4.21" /><polyline points="7.5 19.79 7.5 14.6 3 12" /><polyline points="21 12 16.5 14.6 16.5 19.79" /><polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AccountsIcon({ className }: { className?: string }) {
|
function AccountsIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -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() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [analyticsOpen, setAnalyticsOpen] = useState(pathname.startsWith('/analytics'));
|
||||||
|
const isAnalyticsActive = pathname.startsWith('/analytics');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -116,7 +134,7 @@ export function Sidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 py-4 space-y-1">
|
<nav className="flex-1 py-4 space-y-1 overflow-y-auto">
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
const isActive = pathname === item.href;
|
const isActive = pathname === item.href;
|
||||||
return (
|
return (
|
||||||
@@ -135,6 +153,50 @@ export function Sidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Analytics section */}
|
||||||
|
{!collapsed ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setAnalyticsOpen(!analyticsOpen)}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg transition-all w-full text-left ${
|
||||||
|
isAnalyticsActive ? 'bg-sidebar-active/20 text-white' : 'hover:bg-white/5 text-sidebar-text hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AnalyticsIcon className={`w-5 h-5 flex-shrink-0 ${isAnalyticsActive ? 'text-sidebar-active' : ''}`} />
|
||||||
|
<span className="text-sm font-medium flex-1">Analytics</span>
|
||||||
|
<svg className={`w-3.5 h-3.5 transition-transform ${analyticsOpen ? 'rotate-90' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 18 15 12 9 6" /></svg>
|
||||||
|
</button>
|
||||||
|
{analyticsOpen && (
|
||||||
|
<div className="ml-8 mr-2 space-y-0.5">
|
||||||
|
{analyticsItems.map(item => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`block px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
|
isActive ? 'bg-sidebar-active/20 text-white' : 'text-sidebar-text/70 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/analytics/pipeline-movement"
|
||||||
|
className={`flex items-center justify-center px-2 py-2.5 mx-2 rounded-lg transition-all ${
|
||||||
|
isAnalyticsActive ? 'bg-sidebar-active/20 text-white' : 'hover:bg-white/5 text-sidebar-text hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Analytics"
|
||||||
|
>
|
||||||
|
<AnalyticsIcon className={`w-5 h-5 flex-shrink-0 ${isAnalyticsActive ? 'text-sidebar-active' : ''}`} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className={`${collapsed ? 'px-2' : 'px-4'} pb-4 space-y-3`}>
|
<div className={`${collapsed ? 'px-2' : 'px-4'} pb-4 space-y-3`}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
178
src/lib/db.ts
178
src/lib/db.ts
@@ -15,12 +15,29 @@ import {
|
|||||||
Playbook,
|
Playbook,
|
||||||
PlaybookProgress,
|
PlaybookProgress,
|
||||||
DistrictTarget,
|
DistrictTarget,
|
||||||
|
ForecastLock,
|
||||||
|
ForecastOverride,
|
||||||
} from '@/types/data';
|
} from '@/types/data';
|
||||||
|
|
||||||
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
||||||
|
|
||||||
let _db: Database.Database | null = null;
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
function seedCPMValuesInternal(db: Database.Database) {
|
||||||
|
const cpmAccounts = [
|
||||||
|
'ADT', 'ADVENTHEALTH', 'ARBITRATION FORUMS', 'CARLYLE GROUP', 'CARNIVAL',
|
||||||
|
'ENTERGY', 'Geographic Solutions', 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA',
|
||||||
|
'JABIL CIRCUIT', 'KASEYA', 'LEE MEMORIAL HEALTH SYSTEM', 'LUMEN',
|
||||||
|
'NEXTERA ENERGY', 'NORWEGIAN CRUISE LINE', 'OCHSNER HEALTH SYSTEM',
|
||||||
|
'ORLANDO HEALTH', 'PAYSPAN', 'PUBLIX SUPERMARKETS', 'RAYMOND JAMES FINANCIAL',
|
||||||
|
'TECH DATA',
|
||||||
|
];
|
||||||
|
const stmt = db.prepare("UPDATE accounts SET CPM = 'David Stefan' WHERE Account_Name = ? AND (CPM IS NULL OR CPM = '')");
|
||||||
|
for (const name of cpmAccounts) {
|
||||||
|
stmt.run(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDb(): Database.Database {
|
function getDb(): Database.Database {
|
||||||
if (!_db) {
|
if (!_db) {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -31,8 +48,8 @@ function getDb(): Database.Database {
|
|||||||
_db.pragma('journal_mode = WAL');
|
_db.pragma('journal_mode = WAL');
|
||||||
initSchema(_db);
|
initSchema(_db);
|
||||||
migrateSchema(_db);
|
migrateSchema(_db);
|
||||||
seedDefaultPlaybooks();
|
seedDefaultPlaybooks(_db);
|
||||||
seedCPMValues();
|
seedCPMValuesInternal(_db);
|
||||||
}
|
}
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
@@ -53,6 +70,12 @@ function migrateSchema(db: Database.Database) {
|
|||||||
db.exec("ALTER TABLE accounts ADD COLUMN CPM TEXT DEFAULT NULL");
|
db.exec("ALTER TABLE accounts ADD COLUMN CPM TEXT DEFAULT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pipeCols = db.prepare("PRAGMA table_info(pipeline)").all() as { name: string }[];
|
||||||
|
const pipeColNames = new Set(pipeCols.map(c => c.name));
|
||||||
|
if (!pipeColNames.has('Loss_Reason')) {
|
||||||
|
db.exec("ALTER TABLE pipeline ADD COLUMN Loss_Reason TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
const actCols = db.prepare("PRAGMA table_info(activities)").all() as { name: string }[];
|
const actCols = db.prepare("PRAGMA table_info(activities)").all() as { name: string }[];
|
||||||
const actColNames = new Set(actCols.map(c => c.name));
|
const actColNames = new Set(actCols.map(c => c.name));
|
||||||
if (!actColNames.has('Status')) {
|
if (!actColNames.has('Status')) {
|
||||||
@@ -232,6 +255,34 @@ function initSchema(db: Database.Database) {
|
|||||||
UNIQUE(District_Name, quarter)
|
UNIQUE(District_Name, quarter)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS forecast_locks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lock_date TEXT NOT NULL,
|
||||||
|
quarter TEXT NOT NULL,
|
||||||
|
locked_by TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
total_commit REAL DEFAULT 0,
|
||||||
|
total_best_case REAL DEFAULT 0,
|
||||||
|
total_pipeline REAL DEFAULT 0,
|
||||||
|
deals_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS forecast_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Opportunity_ID TEXT NOT NULL,
|
||||||
|
override_date TEXT NOT NULL,
|
||||||
|
original_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
override_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
original_category TEXT NOT NULL DEFAULT '',
|
||||||
|
override_category TEXT NOT NULL DEFAULT '',
|
||||||
|
original_close_date TEXT,
|
||||||
|
override_close_date TEXT,
|
||||||
|
reason TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forecast_locks_quarter ON forecast_locks(quarter);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forecast_overrides_opp ON forecast_overrides(Opportunity_ID);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
|
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
|
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
|
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
|
||||||
@@ -756,8 +807,8 @@ export function upsertPlaybookProgress(progress: { id?: number; Account_Name: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function seedDefaultPlaybooks() {
|
export function seedDefaultPlaybooks(dbOverride?: Database.Database) {
|
||||||
const db = getDb();
|
const db = dbOverride || getDb();
|
||||||
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
|
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
|
||||||
if (existing.c > 0) return;
|
if (existing.c > 0) return;
|
||||||
|
|
||||||
@@ -839,18 +890,113 @@ export function upsertTarget(record: Record<string, unknown>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedCPMValues() {
|
// --- Forecast Locks ---
|
||||||
|
export function getForecastLocks(quarter?: string): ForecastLock[] {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const cpmAccounts = [
|
if (quarter) return db.prepare('SELECT * FROM forecast_locks WHERE quarter = ? ORDER BY lock_date DESC').all(quarter) as ForecastLock[];
|
||||||
'ADT', 'ADVENTHEALTH', 'ARBITRATION FORUMS', 'CARLYLE GROUP', 'CARNIVAL',
|
return db.prepare('SELECT * FROM forecast_locks ORDER BY lock_date DESC').all() as ForecastLock[];
|
||||||
'ENTERGY', 'Geographic Solutions', 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA',
|
}
|
||||||
'JABIL CIRCUIT', 'KASEYA', 'LEE MEMORIAL HEALTH SYSTEM', 'LUMEN',
|
|
||||||
'NEXTERA ENERGY', 'NORWEGIAN CRUISE LINE', 'OCHSNER HEALTH SYSTEM',
|
export function lockForecast(quarter: string, lockedBy?: string, notes?: string) {
|
||||||
'ORLANDO HEALTH', 'PAYSPAN', 'PUBLIX SUPERMARKETS', 'RAYMOND JAMES FINANCIAL',
|
const db = getDb();
|
||||||
'TECH DATA',
|
const today = new Date().toISOString().split('T')[0];
|
||||||
];
|
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Stage NOT IN ('06-Closed Won','07-Closed Lost')").all() as PipelineRecord[];
|
||||||
const stmt = db.prepare("UPDATE accounts SET CPM = 'David Stefan' WHERE Account_Name = ? AND (CPM IS NULL OR CPM = '')");
|
const overrides = db.prepare('SELECT * FROM forecast_overrides').all() as ForecastOverride[];
|
||||||
for (const name of cpmAccounts) {
|
const overrideMap = new Map<string, ForecastOverride>();
|
||||||
stmt.run(name);
|
overrides.forEach(o => overrideMap.set(o.Opportunity_ID, o));
|
||||||
|
|
||||||
|
const deals = pipeline.map(p => {
|
||||||
|
const ov = overrideMap.get(p.Opportunity_ID);
|
||||||
|
return {
|
||||||
|
Opportunity_ID: p.Opportunity_ID,
|
||||||
|
Account_Name: p.Account_Name,
|
||||||
|
Amount_USD: ov ? ov.override_amount : p.Amount_USD,
|
||||||
|
Forecast_Category: ov ? ov.override_category : p.Forecast_Category,
|
||||||
|
Expected_Close_Date: ov?.override_close_date || p.Expected_Close_Date,
|
||||||
|
Stage: p.Stage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCommit = deals.filter(d => d.Forecast_Category === 'Commit').reduce((s, d) => s + d.Amount_USD, 0);
|
||||||
|
const totalBestCase = deals.filter(d => d.Forecast_Category === 'Best Case').reduce((s, d) => s + d.Amount_USD, 0);
|
||||||
|
const totalPipeline = deals.filter(d => d.Forecast_Category === 'Pipeline').reduce((s, d) => s + d.Amount_USD, 0);
|
||||||
|
|
||||||
|
const result = db.prepare(`INSERT INTO forecast_locks (lock_date, quarter, locked_by, notes, total_commit, total_best_case, total_pipeline, deals_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(today, quarter, lockedBy ?? null, notes ?? null, totalCommit, totalBestCase, totalPipeline, JSON.stringify(deals));
|
||||||
|
return { id: result.lastInsertRowid, lock_date: today, total_commit: totalCommit, total_best_case: totalBestCase, total_pipeline: totalPipeline };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Forecast Overrides ---
|
||||||
|
export function getForecastOverrides(): ForecastOverride[] {
|
||||||
|
return getDb().prepare('SELECT * FROM forecast_overrides ORDER BY override_date DESC').all() as ForecastOverride[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertForecastOverride(override: {
|
||||||
|
Opportunity_ID: string;
|
||||||
|
original_amount: number;
|
||||||
|
override_amount: number;
|
||||||
|
original_category: string;
|
||||||
|
override_category: string;
|
||||||
|
original_close_date?: string | null;
|
||||||
|
override_close_date?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
}) {
|
||||||
|
const db = getDb();
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
db.prepare(`INSERT INTO forecast_overrides (Opportunity_ID, override_date, original_amount, override_amount, original_category, override_category, original_close_date, override_close_date, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET override_amount=excluded.override_amount, override_category=excluded.override_category, override_close_date=excluded.override_close_date, reason=excluded.reason, override_date=excluded.override_date
|
||||||
|
`).run(override.Opportunity_ID, today, override.original_amount, override.override_amount, override.original_category, override.override_category, override.original_close_date ?? null, override.override_close_date ?? null, override.reason ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteForecastOverride(opportunityId: string) {
|
||||||
|
getDb().prepare('DELETE FROM forecast_overrides WHERE Opportunity_ID = ?').run(opportunityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pipeline Movement (waterfall) ---
|
||||||
|
export function getPipelineMovement(fromDate: string, toDate: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const fromSnap = db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(fromDate) as PipelineSnapshot[];
|
||||||
|
const toSnap = db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(toDate) as PipelineSnapshot[];
|
||||||
|
|
||||||
|
if (fromSnap.length === 0 || toSnap.length === 0) return null;
|
||||||
|
|
||||||
|
const fromMap = new Map(fromSnap.map(s => [s.Opportunity_ID, s]));
|
||||||
|
const toMap = new Map(toSnap.map(s => [s.Opportunity_ID, s]));
|
||||||
|
|
||||||
|
let newPipeline = 0, closedWon = 0, closedLost = 0, upside = 0, downside = 0, removed = 0;
|
||||||
|
const details: { type: string; Opportunity_ID: string; Account_Name: string; amount: number }[] = [];
|
||||||
|
|
||||||
|
for (const [id, deal] of toMap) {
|
||||||
|
const prev = fromMap.get(id);
|
||||||
|
if (!prev) {
|
||||||
|
if (deal.Stage === '06-Closed Won') { closedWon += deal.Amount_USD; details.push({ type: 'closed_won', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: deal.Amount_USD }); }
|
||||||
|
else { newPipeline += deal.Amount_USD; details.push({ type: 'new', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: deal.Amount_USD }); }
|
||||||
|
} else {
|
||||||
|
if (deal.Stage === '06-Closed Won' && prev.Stage !== '06-Closed Won') { closedWon += deal.Amount_USD; details.push({ type: 'closed_won', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: deal.Amount_USD }); }
|
||||||
|
else if (deal.Stage === '07-Closed Lost' && prev.Stage !== '07-Closed Lost') { closedLost += prev.Amount_USD; details.push({ type: 'closed_lost', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: prev.Amount_USD }); }
|
||||||
|
else if (deal.Amount_USD > prev.Amount_USD) { upside += deal.Amount_USD - prev.Amount_USD; details.push({ type: 'upside', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: deal.Amount_USD - prev.Amount_USD }); }
|
||||||
|
else if (deal.Amount_USD < prev.Amount_USD) { downside += prev.Amount_USD - deal.Amount_USD; details.push({ type: 'downside', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: prev.Amount_USD - deal.Amount_USD }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [id, deal] of fromMap) {
|
||||||
|
if (!toMap.has(id)) { removed += deal.Amount_USD; details.push({ type: 'removed', Opportunity_ID: id, Account_Name: deal.Account_Name, amount: deal.Amount_USD }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingPipeline = fromSnap.filter(s => s.Stage !== '06-Closed Won' && s.Stage !== '07-Closed Lost').reduce((s, d) => s + d.Amount_USD, 0);
|
||||||
|
const endingPipeline = toSnap.filter(s => s.Stage !== '06-Closed Won' && s.Stage !== '07-Closed Lost').reduce((s, d) => s + d.Amount_USD, 0);
|
||||||
|
|
||||||
|
return { fromDate, toDate, startingPipeline, endingPipeline, newPipeline, closedWon, closedLost, upside, downside, removed, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnapshotDates(): string[] {
|
||||||
|
const db = getDb();
|
||||||
|
return (db.prepare('SELECT DISTINCT snapshot_date FROM pipeline_snapshots ORDER BY snapshot_date DESC').all() as { snapshot_date: string }[]).map(r => r.snapshot_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Win/Loss helpers ---
|
||||||
|
export function updateLossReason(opportunityId: string, lossReason: string) {
|
||||||
|
getDb().prepare('UPDATE pipeline SET Loss_Reason = ? WHERE Opportunity_ID = ?').run(lossReason, opportunityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function generatePipeline(accounts: AccountRecord[]): PipelineRecord[] {
|
|||||||
Stage_Entered_Date: null,
|
Stage_Entered_Date: null,
|
||||||
Tier: account.Tier,
|
Tier: account.Tier,
|
||||||
MAP_In_Place_YN: account.MAP_In_Place_YN,
|
MAP_In_Place_YN: account.MAP_In_Place_YN,
|
||||||
|
Loss_Reason: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface PipelineRecord {
|
|||||||
Stage_Entered_Date: string | null;
|
Stage_Entered_Date: string | null;
|
||||||
Tier: string | null;
|
Tier: string | null;
|
||||||
MAP_In_Place_YN: string | null;
|
MAP_In_Place_YN: string | null;
|
||||||
|
Loss_Reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountRecord {
|
export interface AccountRecord {
|
||||||
@@ -185,6 +186,31 @@ export interface DashboardData {
|
|||||||
lastRefreshed: string;
|
lastRefreshed: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ForecastLock {
|
||||||
|
id: number;
|
||||||
|
lock_date: string;
|
||||||
|
quarter: string;
|
||||||
|
locked_by: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
total_commit: number;
|
||||||
|
total_best_case: number;
|
||||||
|
total_pipeline: number;
|
||||||
|
deals_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastOverride {
|
||||||
|
id: number;
|
||||||
|
Opportunity_ID: string;
|
||||||
|
override_date: string;
|
||||||
|
original_amount: number;
|
||||||
|
override_amount: number;
|
||||||
|
original_category: string;
|
||||||
|
override_category: string;
|
||||||
|
original_close_date: string | null;
|
||||||
|
override_close_date: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardConfig {
|
export interface DashboardConfig {
|
||||||
quotaTarget: number;
|
quotaTarget: number;
|
||||||
weeklyActivityTarget: number;
|
weeklyActivityTarget: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user