- 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>
406 lines
18 KiB
TypeScript
406 lines
18 KiB
TypeScript
'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>
|
|
</>
|
|
);
|
|
}
|