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