import React, { useEffect, useState } from 'react'; import axios from 'axios'; // TODO [MINOR #10]: This TIER_THRESHOLDS array duplicates tiers defined in CpasBadge.jsx // and Dashboard.jsx. Export TIERS from CpasBadge.jsx and import here instead. const TIER_THRESHOLDS = [ { min: 30, label: 'Separation', color: '#ff1744' }, { min: 25, label: 'Final Decision', color: '#ff6d00' }, { min: 20, label: 'Risk Mitigation', color: '#ff9100' }, { min: 15, label: 'Verification', color: '#ffc400' }, { min: 10, label: 'Administrative Lockdown', color: '#ffea00' }, { min: 5, label: 'Realignment', color: '#b2ff59' }, { min: 0, label: 'Elite Standing', color: '#69f0ae' }, ]; function getTier(pts) { return TIER_THRESHOLDS.find(t => pts >= t.min) || TIER_THRESHOLDS[TIER_THRESHOLDS.length - 1]; } function urgencyColor(days) { if (days <= 7) return '#ff4d4f'; if (days <= 14) return '#ffa940'; if (days <= 30) return '#fadb14'; return '#52c41a'; } const s = { wrapper: { marginTop: '24px' }, sectionHd: { fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '10px', }, empty: { color: '#777990', fontStyle: 'italic', fontSize: '12px' }, row: { display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 12px', background: '#181924', borderRadius: '6px', border: '1px solid #2a2b3a', marginBottom: '6px', }, bar: (pct, color) => ({ flex: 1, height: '6px', background: '#2a2b3a', borderRadius: '3px', overflow: 'hidden', position: 'relative', }), barFill: (pct, color) => ({ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${Math.min(100, Math.max(0, 100 - pct))}%`, background: color, borderRadius: '3px', transition: 'width 0.3s ease', }), pill: (color) => ({ display: 'inline-block', padding: '2px 8px', borderRadius: '10px', fontSize: '11px', fontWeight: 700, background: `${color}22`, color, border: `1px solid ${color}55`, whiteSpace: 'nowrap', }), pts: { fontSize: '13px', fontWeight: 700, color: '#f8f9fa', minWidth: '28px', textAlign: 'right' }, name: { fontSize: '12px', color: '#f8f9fa', fontWeight: 600, flex: '0 0 160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, date: { fontSize: '11px', color: '#9ca0b8', minWidth: '88px' }, projBox: { marginTop: '16px', padding: '12px 14px', background: '#0d1117', border: '1px solid #2a2b3a', borderRadius: '6px', fontSize: '12px', color: '#b5b5c0', }, projRow: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }, }; export default function ExpirationTimeline({ employeeId, currentPoints }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); axios.get(`/api/employees/${employeeId}/expiration`) .then(r => setData(r.data)) .finally(() => setLoading(false)); }, [employeeId]); if (loading) return (
Point Roll-Off Timeline
Loading…
); const schedule = data?.schedule || []; if (schedule.length === 0) return (
Point Roll-Off Timeline
No active points — nothing to roll off.
); // Points roll off 5 at a time (oldest first) after each 90 consecutive clean // days. Walk the schedule to project the score and tier after each event. let running = data.active_points ?? currentPoints ?? 0; const projected = schedule.map(ev => { const before = running; running = ev.points_after; const tierBefore = getTier(before); const tierAfter = getTier(running); return { ...ev, pointsBefore: before, tierBefore, tierAfter, tierDropped: tierAfter.min < tierBefore.min, }; }); return (
Point Roll-Off Timeline
5 points roll off after every 90 consecutive days with no new violation. Any new violation resets the countdown. {data.last_violation_date && ( <> Clean since {data.last_violation_date}. )}
{projected.map((ev, i) => { const color = urgencyColor(ev.days_remaining); const pct = (ev.days_remaining / 90) * 100; return (
{/* Roll-off event label */}
Roll-off #{i + 1}
{/* Points retired */}
−{ev.points_off}
{/* Progress bar: how much of the 90-day cycle has elapsed */}
{/* Days remaining pill */}
{ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
{/* Roll-off date */}
{ev.date}
{/* Tier drop indicator */} {ev.tierDropped && (
↓ {ev.tierAfter.label}
)}
); })} {/* Projection summary */}
Projected score after each roll-off
{projected.map((ev) => (
{ev.date} — −{ev.points_off} pts {ev.points_after} pts {ev.tierDropped && ( → {ev.tierAfter.label} )}
))}
); }