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 { 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
|
||||||
|
|||||||
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