feat: ExpirationTimeline component — per-violation roll-off countdown with tier drop projection

This commit is contained in:
2026-03-07 09:43:31 -06:00
parent b02464330b
commit 37efd596dd

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
// Tier thresholds used to compute what tier an employee would drop to
// after a given violation rolls off.
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 [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
axios.get(`/api/employees/${employeeId}/expiration`)
.then(r => setItems(r.data))
.finally(() => setLoading(false));
}, [employeeId]);
if (loading) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={{ ...s.empty }}>Loading</div>
</div>
);
if (items.length === 0) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.empty}>No active violations nothing to expire.</div>
</div>
);
// Build running totals: after each violation expires, what's the remaining score?
let running = currentPoints || 0;
const projected = items.map(item => {
const before = running;
running = Math.max(0, running - item.points);
const tierBefore = getTier(before);
const tierAfter = getTier(running);
const dropped = tierAfter.min < tierBefore.min;
return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped };
});
return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
{projected.map((item) => {
const color = urgencyColor(item.days_remaining);
const pct = (item.days_remaining / 90) * 100;
return (
<div key={item.id} style={s.row}>
{/* Violation name */}
<div style={s.name} title={item.violation_name}>{item.violation_name}</div>
{/* Points badge */}
<div style={s.pts}>{item.points}</div>
{/* Progress bar: how much of the 90 days has elapsed */}
<div style={s.bar(pct, color)}>
<div style={s.barFill(pct, color)} />
</div>
{/* Days remaining pill */}
<div style={s.pill(color)}>
{item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`}
</div>
{/* Expiry date */}
<div style={s.date}>{item.expires_on}</div>
{/* Tier drop indicator */}
{item.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{item.tierAfter.label}
</div>
)}
</div>
);
})}
{/* Projection summary */}
<div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each expiration
</div>
{projected.map((item, i) => (
<div key={item.id} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{item.expires_on} {item.violation_name}</span>
<span>
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span>
{item.tierDropped && (
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}>
{item.tierAfter.label}
</span>
)}
</span>
</div>
))}
</div>
</div>
);
}