diff --git a/src/app/(dashboard)/accounts/page.tsx b/src/app/(dashboard)/accounts/page.tsx index 2dea9f1..f0f1971 100644 --- a/src/app/(dashboard)/accounts/page.tsx +++ b/src/app/(dashboard)/accounts/page.tsx @@ -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() { + {/* Renewal Timeline */} + + {/* Search & Filters */}
= { + won_or_late_stage: '#16A34A', + early_stage: '#D97706', + lost: '#1B1D36', + none: '#9CA3AF', +}; + +const OPP_STATUS_LABELS: Record = { + 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(null); + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); + const svgRef = useRef(null); + const containerRef = useRef(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 ( +
+ + + {!collapsed && ( +
+ {/* Legend and slider */} +
+
+ {(Object.keys(OPP_STATUS_COLORS) as OppStatus[]).map(status => ( +
+
+ {OPP_STATUS_LABELS[status]} +
+ ))} +
+
+ Next Renewal +
+
+
+ Anchor Contract +
+
+ +
+ + Min: {formatCurrency(minAmount, true)} + + setMinAmount(Number(e.target.value))} + className="w-32 h-1.5 accent-brand-azure" + /> + + {pointCount}/{totalCount} shown + +
+
+ + {filteredPoints.length === 0 ? ( +
+ No renewal data for filtered accounts + {minAmount > 0 && ' at this minimum amount'} +
+ ) : ( +
+ + {/* Today marker */} + {(() => { + const todayX = xPos(new Date()); + if (todayX >= MARGIN.left && todayX <= MARGIN.left + chartWidth) { + return ( + + + + Today + + + ); + } + return null; + })()} + + {/* Quarter/year grid lines and labels */} + {quarterTicks.map((tick, i) => { + const x = xPos(tick.date); + return ( + + + + {tick.label} + + + ); + })} + + {/* Horizontal row lines */} + {rows.map((_, i) => { + const y = MARGIN.top + 20 + i * ROW_HEIGHT; + return ( + + ); + })} + + {/* 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 ( + handleMouseEnter(point, e)} + onMouseLeave={handleMouseLeave} + className="cursor-pointer" + > + + + {formatCurrency(point.amount, true)} + + + ); + } else { + return ( + handleMouseEnter(point, e)} + onMouseLeave={handleMouseLeave} + className="cursor-pointer" + > + + + {formatCurrency(point.amount, true)} + + + ); + } + })} + + {/* 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 ( + + {truncated} + + ); + })} + + {/* 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 ( + + + + {hoveredPoint.accountName.length > 22 + ? hoveredPoint.accountName.slice(0, 22) + '…' + : hoveredPoint.accountName} + + + {hoveredPoint.type === 'next' ? 'Next Renewal' : 'Anchor Contract'} + {' · '} + {format(hoveredPoint.date, 'MMM d, yyyy')} + + + {formatCurrency(hoveredPoint.amount, true)} EAR + + + ); + })()} + +
+ )} +
+ )} +
+ ); +}