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:
2026-09-01 08:37:40 -04:00
parent d2d5632086
commit 938e642d68
2 changed files with 507 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ import { ExportButton } from '@/components/ui/ExportButton';
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring'; import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
import { AccountNotes } from '@/components/account/AccountNotes'; import { AccountNotes } from '@/components/account/AccountNotes';
import { ContactMap } from '@/components/account/ContactMap'; import { ContactMap } from '@/components/account/ContactMap';
import { RenewalTimeline } from '@/components/ui/RenewalTimeline';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'; } from 'recharts';
@@ -546,6 +547,9 @@ export default function AccountExplorer() {
</ChartCard> </ChartCard>
</div> </div>
{/* Renewal Timeline */}
<RenewalTimeline accounts={filteredAccounts} pipeline={pipeline} />
{/* Search & Filters */} {/* Search & Filters */}
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
<input <input

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