Add Phase 3: Analytics & Forecast Intelligence

- Pipeline Movement Tracker with waterfall chart and snapshot capture
- Forecast Call Workspace with inline deal overrides and lock history
- Forecast Accuracy tracking against locked snapshots
- Win/Loss Analysis with rate breakdowns by stage, competitor, deal size, play
- Playbook Effectiveness dashboard with conversion funnel and step drop-off
- Account engagement trends with 12-week sparklines and direction badges
- Analytics sidebar navigation with collapsible submenu
- Data layer: forecast_locks, forecast_overrides tables, pipeline movement API
- Fix Turbopack function hoisting issues in Sidebar and db.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:49:05 -04:00
parent a131072226
commit d79794b164
11 changed files with 2646 additions and 30 deletions

View File

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

View File

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

View File

@@ -0,0 +1,553 @@
'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { Scorecard } from '@/components/ui/Scorecard';
import { formatCurrency, CHART_COLORS } from '@/lib/formatters';
import { useData } from '@/lib/data-context';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
/* ---------- Types ---------- */
interface ForecastOverride {
id: string;
Opportunity_ID: string;
override_date: string;
original_amount: number;
override_amount: number;
original_category: string;
override_category: string;
original_close_date?: string;
override_close_date?: string;
reason?: string;
}
interface ForecastLock {
id: string;
lock_date: string;
quarter: string;
locked_by: string;
notes: string;
total_commit: number;
total_best_case: number;
total_pipeline: number;
deals_json: string;
}
interface PipelineDeal {
Opportunity_ID: string;
Account_Name: string;
District_Name: string;
Stage: string;
Amount_USD: number;
Forecast_Category: string;
Expected_Close_Date: string;
Probability_Pct: number;
}
/* ---------- Constants ---------- */
const QUARTERS = ['Q1 FY27', 'Q2 FY27', 'Q3 FY27', 'Q4 FY27'];
const DEFAULT_QUOTA = 2_000_000;
const CLOSED_STAGES = ['06-Closed Won', '07-Closed Lost'];
const CATEGORY_ORDER: Record<string, number> = { Commit: 0, 'Best Case': 1, Pipeline: 2 };
function currentQuarter(): string {
// Today is ~2026-09-01 which maps to Q3 FY27
return 'Q3 FY27';
}
/* ---------- Helpers ---------- */
async function apiFetch<T>(action: string, body: Record<string, unknown> = {}): Promise<T> {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...body }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
/* ---------- Sub-components ---------- */
function LockDialog({
open,
message,
notes,
onNotesChange,
onConfirm,
onCancel,
confirming,
}: {
open: boolean;
message: string;
notes: string;
onNotesChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
confirming: boolean;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-card-bg border border-card-border rounded-xl p-6 max-w-md w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-foreground mb-2">Lock Forecast</h3>
<p className="text-sm text-muted mb-4">{message}</p>
<input
type="text"
placeholder="Optional notes..."
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
className="w-full bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted mb-6"
/>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm rounded-lg border border-card-border text-muted hover:bg-card-bg transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={confirming}
className="px-4 py-2 text-sm rounded-lg bg-brand-azure text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{confirming ? 'Locking...' : 'Lock Forecast'}
</button>
</div>
</div>
</div>
);
}
function InlineEdit({
value,
type,
options,
onSave,
}: {
value: string;
type: 'text' | 'number' | 'date' | 'select';
options?: string[];
onSave: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="text-left hover:text-brand-azure transition-colors underline-offset-2 hover:underline"
title="Click to edit"
>
{type === 'number' ? formatCurrency(Number(value)) : value}
</button>
);
}
const commit = () => {
if (draft !== value) onSave(draft);
setEditing(false);
};
if (type === 'select') {
return (
<select
autoFocus
value={draft}
onChange={(e) => { setDraft(e.target.value); }}
onBlur={commit}
className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground"
>
{options?.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
}
return (
<input
autoFocus
type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
className="bg-card-bg border border-card-border rounded px-2 py-1 text-xs text-foreground w-28"
/>
);
}
/* ---------- Main Page ---------- */
export default function ForecastWorkspacePage() {
const { filtered } = useData();
const { pipeline } = filtered;
const [quarter, setQuarter] = useState(currentQuarter);
const [overrides, setOverrides] = useState<ForecastOverride[]>([]);
const [locks, setLocks] = useState<ForecastLock[]>([]);
const [loading, setLoading] = useState(true);
const [lockDialogOpen, setLockDialogOpen] = useState(false);
const [locking, setLocking] = useState(false);
const [lockNotes, setLockNotes] = useState('');
/* -- Load overrides & locks -- */
const loadData = useCallback(async () => {
setLoading(true);
try {
const [ovRes, lockRes] = await Promise.all([
apiFetch<{ data: ForecastOverride[] }>('forecast.overrides'),
apiFetch<{ data: ForecastLock[] }>('forecast.locks', { quarter }),
]);
setOverrides(ovRes.data ?? []);
setLocks(lockRes.data ?? []);
} catch {
// silently handle — data will be empty
} finally {
setLoading(false);
}
}, [quarter]);
useEffect(() => { loadData(); }, [loadData]);
/* -- Build effective deals: pipeline filtered to open, with overrides applied -- */
const overrideMap = useMemo(() => {
const m = new Map<string, ForecastOverride>();
for (const o of overrides) m.set(o.Opportunity_ID, o);
return m;
}, [overrides]);
const openDeals = useMemo(() => {
return (pipeline as PipelineDeal[])
.filter((d) => !CLOSED_STAGES.includes(d.Stage))
.map((d) => {
const ov = overrideMap.get(d.Opportunity_ID);
return {
...d,
Amount_USD: ov ? ov.override_amount : d.Amount_USD,
Forecast_Category: ov ? ov.override_category : d.Forecast_Category,
Expected_Close_Date: ov?.override_close_date ?? d.Expected_Close_Date,
_overridden: !!ov,
_original: d,
};
})
.filter((d) => ['Commit', 'Best Case', 'Pipeline'].includes(d.Forecast_Category));
}, [pipeline, overrideMap]);
/* -- Aggregates -- */
const totals = useMemo(() => {
let commit = 0, bestCase = 0, pipe = 0;
for (const d of openDeals) {
if (d.Forecast_Category === 'Commit') commit += d.Amount_USD;
else if (d.Forecast_Category === 'Best Case') bestCase += d.Amount_USD;
else if (d.Forecast_Category === 'Pipeline') pipe += d.Amount_USD;
}
const coverage = DEFAULT_QUOTA > 0 ? (commit + bestCase) / DEFAULT_QUOTA : 0;
return { commit, bestCase, pipeline: pipe, coverage };
}, [openDeals]);
/* -- Grouped deals -- */
const grouped = useMemo(() => {
const groups: Record<string, typeof openDeals> = { Commit: [], 'Best Case': [], Pipeline: [] };
for (const d of openDeals) {
if (groups[d.Forecast_Category]) groups[d.Forecast_Category].push(d);
}
// Sort each group by amount desc
for (const k of Object.keys(groups)) {
groups[k].sort((a, b) => b.Amount_USD - a.Amount_USD);
}
return groups;
}, [openDeals]);
/* -- Chart data: stacked by district -- */
const chartData = useMemo(() => {
const byDistrict: Record<string, { Commit: number; 'Best Case': number; Pipeline: number }> = {};
for (const d of openDeals) {
if (!byDistrict[d.District_Name]) {
byDistrict[d.District_Name] = { Commit: 0, 'Best Case': 0, Pipeline: 0 };
}
const cat = d.Forecast_Category as 'Commit' | 'Best Case' | 'Pipeline';
byDistrict[d.District_Name][cat] += d.Amount_USD;
}
return Object.entries(byDistrict)
.map(([name, vals]) => ({ district: name, ...vals }))
.sort((a, b) => (b.Commit + b['Best Case'] + b.Pipeline) - (a.Commit + a['Best Case'] + a.Pipeline));
}, [openDeals]);
/* -- Override handler -- */
const handleOverride = useCallback(
async (deal: (typeof openDeals)[0], field: 'Amount_USD' | 'Forecast_Category' | 'Expected_Close_Date', newValue: string) => {
const orig = deal._original;
const current = { ...deal };
if (field === 'Amount_USD') current.Amount_USD = Number(newValue);
else if (field === 'Forecast_Category') current.Forecast_Category = newValue;
else if (field === 'Expected_Close_Date') current.Expected_Close_Date = newValue;
try {
await apiFetch('forecast.override', {
Opportunity_ID: orig.Opportunity_ID,
original_amount: orig.Amount_USD,
override_amount: field === 'Amount_USD' ? Number(newValue) : current.Amount_USD,
original_category: orig.Forecast_Category,
override_category: field === 'Forecast_Category' ? newValue : current.Forecast_Category,
original_close_date: orig.Expected_Close_Date,
override_close_date: field === 'Expected_Close_Date' ? newValue : current.Expected_Close_Date,
reason: 'Forecast workspace edit',
});
await loadData();
} catch {
// silently handle
}
},
[loadData],
);
/* -- Lock handler -- */
const handleLock = useCallback(async () => {
setLocking(true);
try {
await apiFetch('forecast.lock', { quarter, notes: lockNotes });
setLockDialogOpen(false);
setLockNotes('');
await loadData();
} catch {
// silently handle
} finally {
setLocking(false);
}
}, [quarter, lockNotes, loadData]);
/* ---------- Render ---------- */
return (
<div className="space-y-6">
<PageHeader title="Forecast Workspace" />
{/* Quarter selector & Lock button */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-muted">Quarter</label>
<select
value={quarter}
onChange={(e) => setQuarter(e.target.value)}
className="bg-card-bg border border-card-border rounded-lg px-3 py-2 text-sm text-foreground"
>
{QUARTERS.map((q) => (
<option key={q} value={q}>{q}</option>
))}
</select>
</div>
<button
onClick={() => setLockDialogOpen(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-brand-navy text-white hover:opacity-90 transition-opacity"
>
Lock Forecast
</button>
</div>
{/* Scorecards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Scorecard label="Commit" value={formatCurrency(totals.commit, true)} color="green" />
<Scorecard label="Best Case" value={formatCurrency(totals.bestCase, true)} />
<Scorecard label="Pipeline" value={formatCurrency(totals.pipeline, true)} />
<Scorecard
label="Coverage"
value={`${totals.coverage.toFixed(1)}x`}
subtitle={`vs ${formatCurrency(DEFAULT_QUOTA, true)} quota`}
color={totals.coverage >= 1 ? 'green' : 'red'}
/>
</div>
{/* Loading indicator */}
{loading && (
<div className="text-center py-8 text-muted text-sm">Loading forecast data...</div>
)}
{/* Deal table grouped by category */}
<ChartCard title="Deals by Forecast Category" subtitle="Click any value to override">
<div className="space-y-6">
{(['Commit', 'Best Case', 'Pipeline'] as const).map((cat) => (
<div key={cat}>
<div className="flex items-center gap-2 mb-2">
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor:
cat === 'Commit' ? CHART_COLORS.navy : cat === 'Best Case' ? CHART_COLORS.azure : CHART_COLORS.green,
}}
/>
<h4 className="text-sm font-semibold text-foreground">{cat}</h4>
<span className="text-xs text-muted">
({grouped[cat]?.length ?? 0} deals &middot;{' '}
{formatCurrency(grouped[cat]?.reduce((s, d) => s + d.Amount_USD, 0) ?? 0, true)})
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-card-border">
<th className="pb-2 pr-4 font-medium">Account</th>
<th className="pb-2 pr-4 font-medium">Opportunity</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Amount</th>
<th className="pb-2 pr-4 font-medium">Category</th>
<th className="pb-2 font-medium">Close Date</th>
</tr>
</thead>
<tbody>
{(grouped[cat] ?? []).map((deal) => (
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-card-bg/80">
<td className="py-2 pr-4 text-foreground">
{deal.Account_Name}
{deal._overridden && (
<span className="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-brand-azure/10 text-brand-azure">
edited
</span>
)}
</td>
<td className="py-2 pr-4 text-muted font-mono">{deal.Opportunity_ID}</td>
<td className="py-2 pr-4 text-muted">{deal.Stage}</td>
<td className="py-2 pr-4 text-right">
<InlineEdit
value={String(deal.Amount_USD)}
type="number"
onSave={(v) => handleOverride(deal, 'Amount_USD', v)}
/>
</td>
<td className="py-2 pr-4">
<InlineEdit
value={deal.Forecast_Category}
type="select"
options={['Commit', 'Best Case', 'Pipeline', 'Omit']}
onSave={(v) => handleOverride(deal, 'Forecast_Category', v)}
/>
</td>
<td className="py-2">
<InlineEdit
value={deal.Expected_Close_Date}
type="date"
onSave={(v) => handleOverride(deal, 'Expected_Close_Date', v)}
/>
</td>
</tr>
))}
{(grouped[cat] ?? []).length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-center text-muted">No deals</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
))}
</div>
</ChartCard>
{/* Stacked bar chart by district */}
<ChartCard title="Forecast by District" subtitle="Commit vs Best Case vs Pipeline">
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-card-border)" />
<XAxis dataKey="district" tick={{ fontSize: 11, fill: 'var(--color-muted)' }} />
<YAxis
tickFormatter={(v: number) => formatCurrency(v, true)}
tick={{ fontSize: 11, fill: 'var(--color-muted)' }}
/>
<Tooltip
formatter={(value) => formatCurrency(value as number)}
contentStyle={{
backgroundColor: 'var(--color-card-bg)',
border: '1px solid var(--color-card-border)',
borderRadius: '8px',
fontSize: '12px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="Commit" stackId="a" fill={CHART_COLORS.navy} radius={[0, 0, 0, 0]} />
<Bar dataKey="Best Case" stackId="a" fill={CHART_COLORS.azure} />
<Bar dataKey="Pipeline" stackId="a" fill={CHART_COLORS.green} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</ChartCard>
{/* Forecast History */}
<ChartCard title="Forecast History" subtitle="Past locked snapshots">
{locks.length === 0 && !loading ? (
<p className="text-sm text-muted py-4 text-center">No forecast locks recorded yet for {quarter}.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted border-b border-card-border">
<th className="pb-2 pr-4 font-medium">Lock Date</th>
<th className="pb-2 pr-4 font-medium">Locked By</th>
<th className="pb-2 pr-4 font-medium text-right">Commit</th>
<th className="pb-2 pr-4 font-medium text-right">Best Case</th>
<th className="pb-2 pr-4 font-medium text-right">Pipeline</th>
<th className="pb-2 font-medium">Notes</th>
<th className="pb-2 font-medium text-right">vs Current</th>
</tr>
</thead>
<tbody>
{locks.map((lock) => {
const commitDelta = totals.commit - lock.total_commit;
return (
<tr key={lock.id} className="border-b border-card-border/50">
<td className="py-2 pr-4 text-foreground">
{new Date(lock.lock_date).toLocaleDateString()}
</td>
<td className="py-2 pr-4 text-muted">{lock.locked_by}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_commit, true)}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_best_case, true)}</td>
<td className="py-2 pr-4 text-right text-foreground">{formatCurrency(lock.total_pipeline, true)}</td>
<td className="py-2 pr-4 text-muted">{lock.notes || '—'}</td>
<td className="py-2 text-right">
<span className={commitDelta >= 0 ? 'text-success' : 'text-danger'}>
{commitDelta >= 0 ? '+' : ''}
{formatCurrency(commitDelta, true)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</ChartCard>
{/* Lock Confirm Dialog */}
<LockDialog
open={lockDialogOpen}
message={`Save a snapshot of the current ${quarter} forecast? This records Commit: ${formatCurrency(totals.commit, true)}, Best Case: ${formatCurrency(totals.bestCase, true)}, Pipeline: ${formatCurrency(totals.pipeline, true)}.`}
notes={lockNotes}
onNotesChange={setLockNotes}
onConfirm={handleLock}
onCancel={() => { setLockDialogOpen(false); setLockNotes(''); }}
confirming={locking}
/>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,
});
}
}

View File

@@ -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;