2026-03-07 09:43:31 -06:00
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
|
2026-03-11 16:33:38 -05:00
|
|
|
|
// 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.
|
2026-03-07 09:43:31 -06:00
|
|
|
|
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 }) {
|
2026-05-27 21:41:57 -05:00
|
|
|
|
const [data, setData] = useState(null);
|
2026-03-07 09:43:31 -06:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
axios.get(`/api/employees/${employeeId}/expiration`)
|
2026-05-27 21:41:57 -05:00
|
|
|
|
.then(r => setData(r.data))
|
2026-03-07 09:43:31 -06:00
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
|
}, [employeeId]);
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) return (
|
|
|
|
|
|
<div style={s.wrapper}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
<div style={{ ...s.empty }}>Loading…</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
const schedule = data?.schedule || [];
|
|
|
|
|
|
|
|
|
|
|
|
if (schedule.length === 0) return (
|
2026-03-07 09:43:31 -06:00
|
|
|
|
<div style={s.wrapper}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
|
|
|
|
|
<div style={s.empty}>No active points — nothing to roll off.</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
// 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;
|
2026-03-07 09:43:31 -06:00
|
|
|
|
const tierBefore = getTier(before);
|
|
|
|
|
|
const tierAfter = getTier(running);
|
2026-05-27 21:41:57 -05:00
|
|
|
|
return {
|
|
|
|
|
|
...ev,
|
|
|
|
|
|
pointsBefore: before,
|
|
|
|
|
|
tierBefore,
|
|
|
|
|
|
tierAfter,
|
|
|
|
|
|
tierDropped: tierAfter.min < tierBefore.min,
|
|
|
|
|
|
};
|
2026-03-07 09:43:31 -06:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={s.wrapper}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ ...s.empty, fontStyle: 'normal', marginBottom: '10px', color: '#9ca0b8' }}>
|
|
|
|
|
|
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 <strong style={{ color: '#f8f9fa' }}>{data.last_violation_date}</strong>.</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{projected.map((ev, i) => {
|
|
|
|
|
|
const color = urgencyColor(ev.days_remaining);
|
|
|
|
|
|
const pct = (ev.days_remaining / 90) * 100;
|
2026-03-07 09:43:31 -06:00
|
|
|
|
return (
|
2026-05-27 21:41:57 -05:00
|
|
|
|
<div key={ev.date} style={s.row}>
|
|
|
|
|
|
{/* Roll-off event label */}
|
|
|
|
|
|
<div style={s.name} title={`Roll-off #${i + 1}`}>Roll-off #{i + 1}</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{/* Points retired */}
|
|
|
|
|
|
<div style={s.pts}>−{ev.points_off}</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{/* Progress bar: how much of the 90-day cycle has elapsed */}
|
2026-03-07 09:43:31 -06:00
|
|
|
|
<div style={s.bar(pct, color)}>
|
|
|
|
|
|
<div style={s.barFill(pct, color)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Days remaining pill */}
|
|
|
|
|
|
<div style={s.pill(color)}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
|
2026-03-07 09:43:31 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{/* Roll-off date */}
|
|
|
|
|
|
<div style={s.date}>{ev.date}</div>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
|
|
|
|
|
|
{/* Tier drop indicator */}
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{ev.tierDropped && (
|
2026-03-07 09:43:31 -06:00
|
|
|
|
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
↓ {ev.tierAfter.label}
|
2026-03-07 09:43:31 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Projection summary */}
|
|
|
|
|
|
<div style={s.projBox}>
|
|
|
|
|
|
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
Projected score after each roll-off
|
2026-03-07 09:43:31 -06:00
|
|
|
|
</div>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
{projected.map((ev) => (
|
|
|
|
|
|
<div key={ev.date} style={s.projRow}>
|
|
|
|
|
|
<span style={{ color: '#9ca0b8' }}>{ev.date} — −{ev.points_off} pts</span>
|
2026-03-07 09:43:31 -06:00
|
|
|
|
<span>
|
2026-05-27 21:41:57 -05:00
|
|
|
|
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{ev.points_after} pts</span>
|
|
|
|
|
|
{ev.tierDropped && (
|
|
|
|
|
|
<span style={{ marginLeft: '8px', color: ev.tierAfter.color, fontWeight: 700 }}>
|
|
|
|
|
|
→ {ev.tierAfter.label}
|
2026-03-07 09:43:31 -06:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|