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' };
|
||||
}
|
||||
|
||||
function getEngagementTrend(acctActivities: ActivityRecord[]): { weeklyData: number[]; direction: 'Warming' | 'Cooling' | 'Steady' | 'New' } {
|
||||
const now = new Date();
|
||||
const weeks: number[] = [];
|
||||
for (let w = 11; w >= 0; w--) {
|
||||
const weekStart = new Date(now);
|
||||
weekStart.setDate(weekStart.getDate() - (w + 1) * 7);
|
||||
const weekEnd = new Date(now);
|
||||
weekEnd.setDate(weekEnd.getDate() - w * 7);
|
||||
const ws = weekStart.toISOString().split('T')[0];
|
||||
const we = weekEnd.toISOString().split('T')[0];
|
||||
weeks.push(acctActivities.filter(a => a.Activity_Date >= ws && a.Activity_Date < we).length);
|
||||
}
|
||||
const recent = weeks.slice(8).reduce((s, v) => s + v, 0);
|
||||
const earlier = weeks.slice(4, 8).reduce((s, v) => s + v, 0);
|
||||
const total = weeks.reduce((s, v) => s + v, 0);
|
||||
if (total === 0) return { weeklyData: weeks, direction: 'New' };
|
||||
if (recent > earlier + 1) return { weeklyData: weeks, direction: 'Warming' };
|
||||
if (recent < earlier - 1) return { weeklyData: weeks, direction: 'Cooling' };
|
||||
return { weeklyData: weeks, direction: 'Steady' };
|
||||
}
|
||||
|
||||
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 80;
|
||||
const h = 20;
|
||||
const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * h}`).join(' ');
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="flex-shrink-0">
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TREND_STYLES: Record<string, { color: string; bg: string; label: string }> = {
|
||||
Warming: { color: '#16A34A', bg: '#DCFCE7', label: 'Warming' },
|
||||
Cooling: { color: '#DC2626', bg: '#FEE2E2', label: 'Cooling' },
|
||||
Steady: { color: '#0098C7', bg: '#E8F6FB', label: 'Steady' },
|
||||
New: { color: '#6B7280', bg: '#F3F4F6', label: 'No Activity' },
|
||||
};
|
||||
|
||||
export default function AccountExplorer() {
|
||||
const { filtered, refresh } = useData();
|
||||
const { accounts, pipeline, activities, targets } = filtered;
|
||||
@@ -959,6 +999,8 @@ export default function AccountExplorer() {
|
||||
const suggestion = getSuggestedPriority(account);
|
||||
const mismatch = suggestion.level !== account.Priority;
|
||||
const health = scoreAccountHealth(account, activities, pipeline);
|
||||
const acctActs = activities.filter(a => a.Account_Name === account.Account_Name);
|
||||
const trend = getEngagementTrend(acctActs);
|
||||
return (
|
||||
<div
|
||||
key={account.Account_Name}
|
||||
@@ -1011,6 +1053,13 @@ export default function AccountExplorer() {
|
||||
{health.health_level} ({health.health_score})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MiniSparkline data={trend.weeklyData} color={TREND_STYLES[trend.direction].color} />
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold" style={{ color: TREND_STYLES[trend.direction].color, backgroundColor: TREND_STYLES[trend.direction].bg }}>
|
||||
{TREND_STYLES[trend.direction].label}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted ml-auto">12wk</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted">ARR</div>
|
||||
|
||||
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,
|
||||
getDataQualityReport,
|
||||
getDashboardData,
|
||||
getForecastLocks, lockForecast, getForecastOverrides, upsertForecastOverride, deleteForecastOverride,
|
||||
getPipelineMovement, getSnapshotDates,
|
||||
updateLossReason,
|
||||
} from '@/lib/db';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -68,6 +71,31 @@ export async function POST(req: NextRequest) {
|
||||
case 'quality.report':
|
||||
return NextResponse.json({ data: getDataQualityReport() });
|
||||
|
||||
// Forecast
|
||||
case 'forecast.locks':
|
||||
return NextResponse.json({ data: getForecastLocks(body.quarter) });
|
||||
case 'forecast.lock':
|
||||
return NextResponse.json({ data: lockForecast(body.quarter, body.locked_by, body.notes) });
|
||||
case 'forecast.overrides':
|
||||
return NextResponse.json({ data: getForecastOverrides() });
|
||||
case 'forecast.override':
|
||||
upsertForecastOverride(body);
|
||||
return NextResponse.json({ success: true });
|
||||
case 'forecast.override.delete':
|
||||
deleteForecastOverride(body.Opportunity_ID);
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
// Pipeline Movement
|
||||
case 'pipeline.movement':
|
||||
return NextResponse.json({ data: getPipelineMovement(body.fromDate, body.toDate) });
|
||||
case 'pipeline.snapshot_dates':
|
||||
return NextResponse.json({ data: getSnapshotDates() });
|
||||
|
||||
// Win/Loss
|
||||
case 'pipeline.loss_reason':
|
||||
updateLossReason(body.Opportunity_ID, body.Loss_Reason);
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
// Export
|
||||
case 'export': {
|
||||
const data = getDashboardData();
|
||||
|
||||
@@ -4,18 +4,6 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' },
|
||||
{ href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' },
|
||||
{ href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
|
||||
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
|
||||
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },
|
||||
{ href: '/goals', label: 'Goals & Targets', icon: GoalsIcon, shortLabel: 'Goals' },
|
||||
{ href: '/playbooks', label: 'Playbooks', icon: PlaybookIcon, shortLabel: 'Plays' },
|
||||
{ href: '/reports/status', label: 'Weekly Report', icon: ReportsIcon, shortLabel: 'Report' },
|
||||
{ href: '/reports/executive', label: 'Exec Summary', icon: ExecIcon, shortLabel: 'Summary' },
|
||||
];
|
||||
|
||||
function DashboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<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 }) {
|
||||
return (
|
||||
<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() {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(pathname.startsWith('/analytics'));
|
||||
const isAnalyticsActive = pathname.startsWith('/analytics');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -116,7 +134,7 @@ export function Sidebar() {
|
||||
</svg>
|
||||
</button>
|
||||
</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 => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
@@ -135,6 +153,50 @@ export function Sidebar() {
|
||||
</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>
|
||||
<div className={`${collapsed ? 'px-2' : 'px-4'} pb-4 space-y-3`}>
|
||||
<Link
|
||||
|
||||
180
src/lib/db.ts
180
src/lib/db.ts
@@ -15,12 +15,29 @@ import {
|
||||
Playbook,
|
||||
PlaybookProgress,
|
||||
DistrictTarget,
|
||||
ForecastLock,
|
||||
ForecastOverride,
|
||||
} from '@/types/data';
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
||||
|
||||
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 {
|
||||
if (!_db) {
|
||||
const fs = require('fs');
|
||||
@@ -31,8 +48,8 @@ function getDb(): Database.Database {
|
||||
_db.pragma('journal_mode = WAL');
|
||||
initSchema(_db);
|
||||
migrateSchema(_db);
|
||||
seedDefaultPlaybooks();
|
||||
seedCPMValues();
|
||||
seedDefaultPlaybooks(_db);
|
||||
seedCPMValuesInternal(_db);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
@@ -53,6 +70,12 @@ function migrateSchema(db: Database.Database) {
|
||||
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 actColNames = new Set(actCols.map(c => c.name));
|
||||
if (!actColNames.has('Status')) {
|
||||
@@ -232,6 +255,34 @@ function initSchema(db: Database.Database) {
|
||||
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_district ON pipeline(District_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() {
|
||||
const db = getDb();
|
||||
export function seedDefaultPlaybooks(dbOverride?: Database.Database) {
|
||||
const db = dbOverride || getDb();
|
||||
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
|
||||
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 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);
|
||||
}
|
||||
if (quarter) return db.prepare('SELECT * FROM forecast_locks WHERE quarter = ? ORDER BY lock_date DESC').all(quarter) as ForecastLock[];
|
||||
return db.prepare('SELECT * FROM forecast_locks ORDER BY lock_date DESC').all() as ForecastLock[];
|
||||
}
|
||||
|
||||
export function lockForecast(quarter: string, lockedBy?: string, notes?: string) {
|
||||
const db = getDb();
|
||||
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 overrides = db.prepare('SELECT * FROM forecast_overrides').all() as ForecastOverride[];
|
||||
const overrideMap = new Map<string, ForecastOverride>();
|
||||
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,
|
||||
Tier: account.Tier,
|
||||
MAP_In_Place_YN: account.MAP_In_Place_YN,
|
||||
Loss_Reason: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface PipelineRecord {
|
||||
Stage_Entered_Date: string | null;
|
||||
Tier: string | null;
|
||||
MAP_In_Place_YN: string | null;
|
||||
Loss_Reason: string | null;
|
||||
}
|
||||
|
||||
export interface AccountRecord {
|
||||
@@ -185,6 +186,31 @@ export interface DashboardData {
|
||||
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 {
|
||||
quotaTarget: number;
|
||||
weeklyActivityTarget: number;
|
||||
|
||||
Reference in New Issue
Block a user