Add Renewal Timeline visualization to Account Explorer
Collapsible SVG timeline showing Next Renewal and Anchor Contract dates for all filtered accounts. Color-coded account names by opportunity status (green=won/late stage, yellow=early stage, black=lost, gray=none). Includes dollar amount slider filter, Today marker, and year/quarter gridlines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { ExportButton } from '@/components/ui/ExportButton';
|
||||
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
|
||||
import { AccountNotes } from '@/components/account/AccountNotes';
|
||||
import { ContactMap } from '@/components/account/ContactMap';
|
||||
import { RenewalTimeline } from '@/components/ui/RenewalTimeline';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
@@ -546,6 +547,9 @@ export default function AccountExplorer() {
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Renewal Timeline */}
|
||||
<RenewalTimeline accounts={filteredAccounts} pipeline={pipeline} />
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<input
|
||||
|
||||
503
src/components/ui/RenewalTimeline.tsx
Normal file
503
src/components/ui/RenewalTimeline.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { parseISO, format } from 'date-fns';
|
||||
import { AccountRecord, PipelineRecord } from '@/types/data';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
|
||||
type OppStatus = 'won_or_late_stage' | 'early_stage' | 'lost' | 'none';
|
||||
|
||||
const OPP_STATUS_COLORS: Record<OppStatus, string> = {
|
||||
won_or_late_stage: '#16A34A',
|
||||
early_stage: '#D97706',
|
||||
lost: '#1B1D36',
|
||||
none: '#9CA3AF',
|
||||
};
|
||||
|
||||
const OPP_STATUS_LABELS: Record<OppStatus, string> = {
|
||||
won_or_late_stage: 'Closed Won / Late Stage Opp',
|
||||
early_stage: 'Early Stage Opp',
|
||||
lost: 'Lost (no active opp)',
|
||||
none: 'No Opportunity',
|
||||
};
|
||||
|
||||
function getAccountOppStatus(accountName: string, pipeline: PipelineRecord[]): OppStatus {
|
||||
const opps = pipeline.filter(p => p.Account_Name === accountName);
|
||||
if (opps.length === 0) return 'none';
|
||||
|
||||
const hasClosedWon = opps.some(p => p.Stage === '06-Closed Won');
|
||||
const hasLateStage = opps.some(p =>
|
||||
p.Stage === '04-Business Case' || p.Stage === '05-Negotiation'
|
||||
);
|
||||
const hasEarlyStage = opps.some(p =>
|
||||
p.Stage === '01-Qualified' || p.Stage === '02-Discovery' || p.Stage === '03-Evaluation'
|
||||
);
|
||||
const hasLost = opps.some(p => p.Stage === '07-Closed Lost');
|
||||
|
||||
if (hasClosedWon || hasLateStage) return 'won_or_late_stage';
|
||||
if (hasEarlyStage) return 'early_stage';
|
||||
if (hasLost) return 'lost';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
interface RenewalPoint {
|
||||
accountName: string;
|
||||
date: Date;
|
||||
amount: number;
|
||||
type: 'next' | 'anchor';
|
||||
oppStatus: OppStatus;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
accounts: AccountRecord[];
|
||||
pipeline: PipelineRecord[];
|
||||
}
|
||||
|
||||
export function RenewalTimeline({ accounts, pipeline }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [minAmount, setMinAmount] = useState(0);
|
||||
const [hoveredPoint, setHoveredPoint] = useState<RenewalPoint | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allPoints = useMemo(() => {
|
||||
const points: RenewalPoint[] = [];
|
||||
accounts.forEach(account => {
|
||||
const oppStatus = getAccountOppStatus(account.Account_Name, pipeline);
|
||||
if (account.Next_Renewal_Date && account.Next_Renewal_EAR) {
|
||||
points.push({
|
||||
accountName: account.Account_Name,
|
||||
date: parseISO(account.Next_Renewal_Date),
|
||||
amount: account.Next_Renewal_EAR,
|
||||
type: 'next',
|
||||
oppStatus,
|
||||
});
|
||||
}
|
||||
if (account.Anchor_Contract_Date && account.Anchor_Contract_EAR) {
|
||||
points.push({
|
||||
accountName: account.Account_Name,
|
||||
date: parseISO(account.Anchor_Contract_Date),
|
||||
amount: account.Anchor_Contract_EAR,
|
||||
type: 'anchor',
|
||||
oppStatus,
|
||||
});
|
||||
}
|
||||
});
|
||||
return points.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}, [accounts, pipeline]);
|
||||
|
||||
const maxAmount = useMemo(() => {
|
||||
if (allPoints.length === 0) return 100000;
|
||||
return Math.max(...allPoints.map(p => p.amount));
|
||||
}, [allPoints]);
|
||||
|
||||
const sliderStep = useMemo(() => {
|
||||
if (maxAmount <= 100000) return 5000;
|
||||
if (maxAmount <= 1000000) return 25000;
|
||||
return 100000;
|
||||
}, [maxAmount]);
|
||||
|
||||
const filteredPoints = useMemo(() => {
|
||||
return allPoints.filter(p => p.amount >= minAmount);
|
||||
}, [allPoints, minAmount]);
|
||||
|
||||
const { minDate, maxDate, years } = useMemo(() => {
|
||||
if (filteredPoints.length === 0) {
|
||||
const now = new Date();
|
||||
return {
|
||||
minDate: new Date(now.getFullYear(), 0, 1),
|
||||
maxDate: new Date(now.getFullYear() + 1, 11, 31),
|
||||
years: [now.getFullYear(), now.getFullYear() + 1],
|
||||
};
|
||||
}
|
||||
const dates = filteredPoints.map(p => p.date.getTime());
|
||||
const earliest = new Date(Math.min(...dates));
|
||||
const latest = new Date(Math.max(...dates));
|
||||
const startYear = earliest.getFullYear();
|
||||
const endYear = latest.getFullYear();
|
||||
const yrs: number[] = [];
|
||||
for (let y = startYear; y <= endYear; y++) yrs.push(y);
|
||||
return {
|
||||
minDate: new Date(startYear, 0, 1),
|
||||
maxDate: new Date(endYear, 11, 31),
|
||||
years: yrs,
|
||||
};
|
||||
}, [filteredPoints]);
|
||||
|
||||
const MARGIN = { left: 60, right: 40, top: 30, bottom: 60 };
|
||||
const [containerWidth, setContainerWidth] = useState(900);
|
||||
const ROW_HEIGHT = 26;
|
||||
const MIN_CHART_HEIGHT = 200;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || collapsed) return;
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, [collapsed]);
|
||||
|
||||
const { rows, chartHeight, svgHeight } = useMemo(() => {
|
||||
const uniqueAccounts = Array.from(new Set(filteredPoints.map(p => p.accountName)));
|
||||
const h = Math.max(MIN_CHART_HEIGHT, uniqueAccounts.length * ROW_HEIGHT + 40);
|
||||
return {
|
||||
rows: uniqueAccounts,
|
||||
chartHeight: h,
|
||||
svgHeight: h + MARGIN.top + MARGIN.bottom,
|
||||
};
|
||||
}, [filteredPoints]);
|
||||
|
||||
const chartWidth = containerWidth - MARGIN.left - MARGIN.right;
|
||||
const totalMs = maxDate.getTime() - minDate.getTime();
|
||||
|
||||
function xPos(date: Date) {
|
||||
if (totalMs === 0) return MARGIN.left + chartWidth / 2;
|
||||
return MARGIN.left + ((date.getTime() - minDate.getTime()) / totalMs) * chartWidth;
|
||||
}
|
||||
|
||||
function yPos(accountName: string) {
|
||||
const idx = rows.indexOf(accountName);
|
||||
if (idx === -1) return MARGIN.top;
|
||||
return MARGIN.top + 20 + idx * ROW_HEIGHT;
|
||||
}
|
||||
|
||||
const quarterTicks = useMemo(() => {
|
||||
const ticks: { date: Date; label: string; isYear: boolean }[] = [];
|
||||
years.forEach(year => {
|
||||
for (let q = 0; q < 4; q++) {
|
||||
const d = new Date(year, q * 3, 1);
|
||||
if (d >= minDate && d <= maxDate) {
|
||||
ticks.push({
|
||||
date: d,
|
||||
label: q === 0 ? `${year}` : `Q${q + 1}`,
|
||||
isYear: q === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return ticks;
|
||||
}, [years, minDate, maxDate]);
|
||||
|
||||
const handleMouseEnter = (point: RenewalPoint, e: React.MouseEvent) => {
|
||||
const svgRect = svgRef.current?.getBoundingClientRect();
|
||||
if (svgRect) {
|
||||
setTooltipPos({
|
||||
x: e.clientX - svgRect.left,
|
||||
y: e.clientY - svgRect.top - 10,
|
||||
});
|
||||
}
|
||||
setHoveredPoint(point);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredPoint(null);
|
||||
};
|
||||
|
||||
const pointCount = filteredPoints.length;
|
||||
const totalCount = allPoints.length;
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg rounded-xl border border-card-border mb-4">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center justify-between px-5 py-3 hover:bg-gray-50 transition rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className={`w-4 h-4 text-muted transition-transform ${collapsed ? '' : 'rotate-90'}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<h3 className="text-sm font-semibold text-foreground">Renewal Timeline</h3>
|
||||
<span className="text-[10px] text-muted">
|
||||
{totalCount} renewal{totalCount !== 1 ? 's' : ''} across {accounts.length} account{accounts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-muted transition-transform ${collapsed ? '' : 'rotate-180'}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-5 pb-5 pt-1" ref={containerRef}>
|
||||
{/* Legend and slider */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{(Object.keys(OPP_STATUS_COLORS) as OppStatus[]).map(status => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: OPP_STATUS_COLORS[status] }}
|
||||
/>
|
||||
<span className="text-[10px] text-muted">{OPP_STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<div className="w-3 h-3 rounded-full border-2 border-brand-azure bg-white" />
|
||||
<span className="text-[10px] text-muted">Next Renewal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded border-2 border-brand-azure bg-white rotate-45" style={{ borderRadius: 2 }} />
|
||||
<span className="text-[10px] text-muted">Anchor Contract</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted whitespace-nowrap">
|
||||
Min: {formatCurrency(minAmount, true)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxAmount}
|
||||
step={sliderStep}
|
||||
value={minAmount}
|
||||
onChange={e => setMinAmount(Number(e.target.value))}
|
||||
className="w-32 h-1.5 accent-brand-azure"
|
||||
/>
|
||||
<span className="text-[10px] text-muted whitespace-nowrap">
|
||||
{pointCount}/{totalCount} shown
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPoints.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted">
|
||||
No renewal data for filtered accounts
|
||||
{minAmount > 0 && ' at this minimum amount'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={containerWidth}
|
||||
height={svgHeight}
|
||||
className="select-none"
|
||||
style={{ minWidth: 600 }}
|
||||
>
|
||||
{/* Today marker */}
|
||||
{(() => {
|
||||
const todayX = xPos(new Date());
|
||||
if (todayX >= MARGIN.left && todayX <= MARGIN.left + chartWidth) {
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={todayX}
|
||||
y1={MARGIN.top}
|
||||
x2={todayX}
|
||||
y2={MARGIN.top + chartHeight}
|
||||
stroke="#EF4444"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
opacity={0.6}
|
||||
/>
|
||||
<text
|
||||
x={todayX}
|
||||
y={MARGIN.top - 6}
|
||||
textAnchor="middle"
|
||||
fill="#EF4444"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
>
|
||||
Today
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Quarter/year grid lines and labels */}
|
||||
{quarterTicks.map((tick, i) => {
|
||||
const x = xPos(tick.date);
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={x}
|
||||
y1={MARGIN.top}
|
||||
x2={x}
|
||||
y2={MARGIN.top + chartHeight}
|
||||
stroke={tick.isYear ? '#CBD5E1' : '#E2E8F0'}
|
||||
strokeWidth={tick.isYear ? 1.5 : 1}
|
||||
strokeDasharray={tick.isYear ? undefined : '3 3'}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={MARGIN.top + chartHeight + (tick.isYear ? 18 : 14)}
|
||||
textAnchor="middle"
|
||||
fill={tick.isYear ? '#1B1D36' : '#94A3B8'}
|
||||
fontSize={tick.isYear ? 12 : 10}
|
||||
fontWeight={tick.isYear ? 700 : 400}
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Horizontal row lines */}
|
||||
{rows.map((_, i) => {
|
||||
const y = MARGIN.top + 20 + i * ROW_HEIGHT;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={MARGIN.left}
|
||||
x2={MARGIN.left + chartWidth}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="#F1F5F9"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data points */}
|
||||
{filteredPoints.map((point, i) => {
|
||||
const x = xPos(point.date);
|
||||
const y = yPos(point.accountName);
|
||||
const color = OPP_STATUS_COLORS[point.oppStatus];
|
||||
const isHovered = hoveredPoint === point;
|
||||
|
||||
if (point.type === 'next') {
|
||||
return (
|
||||
<g
|
||||
key={`${point.accountName}-${point.type}-${i}`}
|
||||
onMouseEnter={e => handleMouseEnter(point, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isHovered ? 7 : 5}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
opacity={isHovered ? 1 : 0.85}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={y - 9}
|
||||
textAnchor="middle"
|
||||
fill={color}
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
{formatCurrency(point.amount, true)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<g
|
||||
key={`${point.accountName}-${point.type}-${i}`}
|
||||
onMouseEnter={e => handleMouseEnter(point, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<rect
|
||||
x={x - (isHovered ? 5.5 : 4)}
|
||||
y={y - (isHovered ? 5.5 : 4)}
|
||||
width={isHovered ? 11 : 8}
|
||||
height={isHovered ? 11 : 8}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
transform={`rotate(45 ${x} ${y})`}
|
||||
opacity={isHovered ? 1 : 0.85}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={y - 9}
|
||||
textAnchor="middle"
|
||||
fill={color}
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
{formatCurrency(point.amount, true)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Account name labels on the left */}
|
||||
{rows.map((accountName, i) => {
|
||||
const y = MARGIN.top + 20 + i * ROW_HEIGHT;
|
||||
const oppStatus = getAccountOppStatus(accountName, pipeline);
|
||||
const color = OPP_STATUS_COLORS[oppStatus];
|
||||
const truncated = accountName.length > 8 ? accountName.slice(0, 8) + '…' : accountName;
|
||||
return (
|
||||
<text
|
||||
key={accountName}
|
||||
x={MARGIN.left - 6}
|
||||
y={y + 3.5}
|
||||
textAnchor="end"
|
||||
fill={color}
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
>
|
||||
{truncated}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && (() => {
|
||||
const tipW = 180;
|
||||
const tipH = 56;
|
||||
let tx = tooltipPos.x - tipW / 2;
|
||||
let ty = tooltipPos.y - tipH - 8;
|
||||
if (tx < 4) tx = 4;
|
||||
if (tx + tipW > containerWidth - 4) tx = containerWidth - tipW - 4;
|
||||
if (ty < 4) ty = tooltipPos.y + 16;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={tx}
|
||||
y={ty}
|
||||
width={tipW}
|
||||
height={tipH}
|
||||
rx={6}
|
||||
fill="#1B1D36"
|
||||
opacity={0.95}
|
||||
/>
|
||||
<text x={tx + 10} y={ty + 16} fill="white" fontSize={10} fontWeight={600}>
|
||||
{hoveredPoint.accountName.length > 22
|
||||
? hoveredPoint.accountName.slice(0, 22) + '…'
|
||||
: hoveredPoint.accountName}
|
||||
</text>
|
||||
<text x={tx + 10} y={ty + 30} fill="#94A3B8" fontSize={9}>
|
||||
{hoveredPoint.type === 'next' ? 'Next Renewal' : 'Anchor Contract'}
|
||||
{' · '}
|
||||
{format(hoveredPoint.date, 'MMM d, yyyy')}
|
||||
</text>
|
||||
<text x={tx + 10} y={ty + 44} fill="#0098C7" fontSize={11} fontWeight={700}>
|
||||
{formatCurrency(hoveredPoint.amount, true)} EAR
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user