Files
cpas/client/src/components/ExpirationTimeline.jsx
T
jason 6ce9788a6b
Build and Push Docker Image / build (push) Successful in 16s
90 day rolloff fix
2026-05-27 21:41:57 -05:00

176 lines
6.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={{ ...s.empty }}>Loading</div>
</div>
);
const schedule = data?.schedule || [];
if (schedule.length === 0) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={s.empty}>No active points nothing to roll off.</div>
</div>
);
// 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 (
<div style={s.wrapper}>
<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>
{projected.map((ev, i) => {
const color = urgencyColor(ev.days_remaining);
const pct = (ev.days_remaining / 90) * 100;
return (
<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>
{/* Points retired */}
<div style={s.pts}>{ev.points_off}</div>
{/* Progress bar: how much of the 90-day cycle has elapsed */}
<div style={s.bar(pct, color)}>
<div style={s.barFill(pct, color)} />
</div>
{/* Days remaining pill */}
<div style={s.pill(color)}>
{ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
</div>
{/* Roll-off date */}
<div style={s.date}>{ev.date}</div>
{/* Tier drop indicator */}
{ev.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{ev.tierAfter.label}
</div>
)}
</div>
);
})}
{/* Projection summary */}
<div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each roll-off
</div>
{projected.map((ev) => (
<div key={ev.date} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{ev.date} {ev.points_off} pts</span>
<span>
<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}
</span>
)}
</span>
</div>
))}
</div>
</div>
);
}