2026-03-06 12:53:40 -06:00
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import CpasBadge, { getTier } from './CpasBadge';
|
|
|
|
|
|
import EmployeeModal from './EmployeeModal';
|
2026-03-07 09:26:33 -06:00
|
|
|
|
import AuditLog from './AuditLog';
|
2026-03-08 22:05:52 -05:00
|
|
|
|
import DashboardMobile from './DashboardMobile';
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
const AT_RISK_THRESHOLD = 2;
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
|
|
|
|
|
const TIERS = [
|
2026-03-06 17:43:14 -06:00
|
|
|
|
{ min: 0, max: 4 },
|
|
|
|
|
|
{ min: 5, max: 9 },
|
|
|
|
|
|
{ min: 10, max: 14 },
|
|
|
|
|
|
{ min: 15, max: 19 },
|
|
|
|
|
|
{ min: 20, max: 24 },
|
|
|
|
|
|
{ min: 25, max: 29 },
|
|
|
|
|
|
{ min: 30, max: 999 },
|
2026-03-06 12:53:40 -06:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function nextTierBoundary(points) {
|
2026-03-06 14:11:46 -06:00
|
|
|
|
for (const t of TIERS) {
|
|
|
|
|
|
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
2026-03-06 12:53:40 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isAtRisk(points) {
|
2026-03-06 14:11:46 -06:00
|
|
|
|
const boundary = nextTierBoundary(points);
|
|
|
|
|
|
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
2026-03-06 12:53:40 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 16:33:38 -05:00
|
|
|
|
// TODO [MAJOR #8]: Same hook is defined in App.jsx — extract to src/hooks/useMediaQuery.js
|
|
|
|
|
|
// Also: `matches` in the dep array can cause a loop on strict-mode initial mount.
|
2026-03-08 22:05:52 -05:00
|
|
|
|
function useMediaQuery(query) {
|
|
|
|
|
|
const [matches, setMatches] = useState(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const media = window.matchMedia(query);
|
|
|
|
|
|
if (media.matches !== matches) setMatches(media.matches);
|
|
|
|
|
|
const listener = () => setMatches(media.matches);
|
|
|
|
|
|
media.addEventListener('change', listener);
|
|
|
|
|
|
return () => media.removeEventListener('change', listener);
|
|
|
|
|
|
}, [matches, query]);
|
|
|
|
|
|
return matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 00:11:42 -05:00
|
|
|
|
// Filter keys
|
|
|
|
|
|
const FILTER_NONE = null;
|
|
|
|
|
|
const FILTER_TOTAL = 'total';
|
|
|
|
|
|
const FILTER_ELITE = 'elite';
|
|
|
|
|
|
const FILTER_ACTIVE = 'active';
|
|
|
|
|
|
const FILTER_AT_RISK = 'at_risk';
|
|
|
|
|
|
|
2026-03-06 12:53:40 -06:00
|
|
|
|
const s = {
|
2026-03-07 09:26:33 -06:00
|
|
|
|
wrap: { padding: '32px 40px', color: '#f8f9fa' },
|
|
|
|
|
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
|
|
|
|
|
|
title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' },
|
|
|
|
|
|
subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' },
|
|
|
|
|
|
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
|
2026-03-11 00:11:42 -05:00
|
|
|
|
statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #303136', borderRadius: '8px', padding: '16px', textAlign: 'center', cursor: 'pointer', transition: 'border-color 0.15s, box-shadow 0.15s' },
|
|
|
|
|
|
statCardActive: { boxShadow: '0 0 0 2px #d4af37', border: '1px solid #d4af37' },
|
2026-03-07 09:26:33 -06:00
|
|
|
|
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
|
|
|
|
|
|
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
|
2026-03-11 00:11:42 -05:00
|
|
|
|
filterBadge: { fontSize: '10px', color: '#d4af37', marginTop: '4px', fontWeight: 600 },
|
2026-03-07 09:26:33 -06:00
|
|
|
|
search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' },
|
|
|
|
|
|
table: { width: '100%', borderCollapse: 'collapse', background: '#111217', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 8px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
|
|
|
|
|
th: { background: '#000000', color: '#f8f9fa', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
|
|
|
|
|
|
td: { padding: '11px 14px', borderBottom: '1px solid #1c1d29', fontSize: '13px', verticalAlign: 'middle', color: '#f8f9fa' },
|
|
|
|
|
|
nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#d4af37', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' },
|
|
|
|
|
|
atRiskBadge: { display: 'inline-block', marginLeft: '8px', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37', verticalAlign: 'middle' },
|
|
|
|
|
|
zeroRow: { color: '#77798a', fontStyle: 'italic', fontSize: '12px' },
|
|
|
|
|
|
toolbarRight: { display: 'flex', gap: '10px', alignItems: 'center' },
|
|
|
|
|
|
refreshBtn: { padding: '9px 18px', background: '#d4af37', color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
|
|
|
|
|
auditBtn: { padding: '9px 18px', background: 'none', color: '#9ca0b8', border: '1px solid #2a2b3a', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
2026-03-06 12:53:40 -06:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 22:05:52 -05:00
|
|
|
|
// Mobile styles
|
|
|
|
|
|
const mobileStyles = `
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.dashboard-wrap {
|
|
|
|
|
|
padding: 16px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dashboard-header {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dashboard-title {
|
|
|
|
|
|
font-size: 20px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dashboard-subtitle {
|
|
|
|
|
|
font-size: 12px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dashboard-stats {
|
|
|
|
|
|
gap: 10px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dashboard-stat-card {
|
|
|
|
|
|
min-width: calc(50% - 5px) !important;
|
|
|
|
|
|
padding: 12px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-num {
|
|
|
|
|
|
font-size: 24px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-lbl {
|
|
|
|
|
|
font-size: 10px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.toolbar-right {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
.search-input {
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.toolbar-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 480px) {
|
|
|
|
|
|
.dashboard-stat-card {
|
|
|
|
|
|
min-width: 100% !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
2026-03-06 12:53:40 -06:00
|
|
|
|
export default function Dashboard() {
|
2026-03-07 09:26:33 -06:00
|
|
|
|
const [employees, setEmployees] = useState([]);
|
|
|
|
|
|
const [filtered, setFiltered] = useState([]);
|
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
|
const [selectedId, setSelectedId] = useState(null);
|
|
|
|
|
|
const [showAudit, setShowAudit] = useState(false);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-03-11 00:11:42 -05:00
|
|
|
|
const [activeFilter, setActiveFilter] = useState(FILTER_NONE);
|
2026-03-08 22:05:52 -05:00
|
|
|
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
const load = useCallback(() => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
axios.get('/api/dashboard')
|
|
|
|
|
|
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
|
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
|
}, []);
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
useEffect(() => { load(); }, [load]);
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-11 00:11:42 -05:00
|
|
|
|
// Apply search + badge filter together
|
2026-03-06 14:11:46 -06:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const q = search.toLowerCase();
|
2026-03-11 00:11:42 -05:00
|
|
|
|
let base = employees;
|
|
|
|
|
|
|
|
|
|
|
|
if (activeFilter === FILTER_ELITE) {
|
|
|
|
|
|
base = base.filter(e => e.active_points >= 0 && e.active_points <= 4);
|
|
|
|
|
|
} else if (activeFilter === FILTER_ACTIVE) {
|
|
|
|
|
|
base = base.filter(e => e.active_points > 0);
|
|
|
|
|
|
} else if (activeFilter === FILTER_AT_RISK) {
|
|
|
|
|
|
base = base.filter(e => isAtRisk(e.active_points));
|
|
|
|
|
|
}
|
|
|
|
|
|
// FILTER_TOTAL and FILTER_NONE show all
|
|
|
|
|
|
|
|
|
|
|
|
if (q) {
|
|
|
|
|
|
base = base.filter(e =>
|
|
|
|
|
|
e.name.toLowerCase().includes(q) ||
|
|
|
|
|
|
(e.department || '').toLowerCase().includes(q) ||
|
|
|
|
|
|
(e.supervisor || '').toLowerCase().includes(q)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setFiltered(base);
|
|
|
|
|
|
}, [search, employees, activeFilter]);
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
|
|
|
|
|
const activeCount = employees.filter(e => e.active_points > 0).length;
|
2026-03-11 00:11:42 -05:00
|
|
|
|
// Elite Standing: 0–4 pts (Tier 0-1)
|
|
|
|
|
|
const eliteCount = employees.filter(e => e.active_points >= 0 && e.active_points <= 4).length;
|
2026-03-06 14:11:46 -06:00
|
|
|
|
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-11 00:11:42 -05:00
|
|
|
|
function handleBadgeClick(filterKey) {
|
|
|
|
|
|
setActiveFilter(prev => prev === filterKey ? FILTER_NONE : filterKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cardStyle(filterKey, extra = {}) {
|
|
|
|
|
|
const isActive = activeFilter === filterKey;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...s.statCard,
|
|
|
|
|
|
...(isActive ? s.statCardActive : {}),
|
|
|
|
|
|
...extra,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
return (
|
2026-03-06 17:43:14 -06:00
|
|
|
|
<>
|
2026-03-11 16:33:38 -05:00
|
|
|
|
{/* TODO [MAJOR #9]: Same mobileStyles block exists in App.jsx. Move to mobile.css */}
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<style>{mobileStyles}</style>
|
|
|
|
|
|
<div style={s.wrap} className="dashboard-wrap">
|
|
|
|
|
|
<div style={s.header} className="dashboard-header">
|
2026-03-06 17:43:14 -06:00
|
|
|
|
<div>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={s.title} className="dashboard-title">Company Dashboard</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
<div style={s.subtitle} className="dashboard-subtitle">
|
|
|
|
|
|
Click any employee name to view their full profile
|
|
|
|
|
|
{activeFilter && activeFilter !== FILTER_NONE && (
|
|
|
|
|
|
<span style={{ marginLeft: '10px', color: '#d4af37', fontWeight: 600 }}>
|
|
|
|
|
|
· Filtered: {activeFilter === FILTER_ELITE ? 'Elite Standing (0–4 pts)' : activeFilter === FILTER_ACTIVE ? 'With Active Points' : activeFilter === FILTER_AT_RISK ? 'At Risk' : 'All'}
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setActiveFilter(FILTER_NONE)}
|
|
|
|
|
|
style={{ marginLeft: '6px', background: 'none', border: 'none', color: '#9ca0b8', cursor: 'pointer', fontSize: '12px' }}
|
|
|
|
|
|
title="Clear filter"
|
|
|
|
|
|
>✕</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={s.toolbarRight} className="toolbar-right">
|
2026-03-06 17:43:14 -06:00
|
|
|
|
<input
|
|
|
|
|
|
style={s.search}
|
2026-03-08 22:05:52 -05:00
|
|
|
|
className="search-input"
|
2026-03-06 17:43:14 -06:00
|
|
|
|
placeholder="Search name, dept, supervisor…"
|
|
|
|
|
|
value={search}
|
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
|
/>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<button style={s.auditBtn} className="toolbar-btn" onClick={() => setShowAudit(true)}>📋 Audit Log</button>
|
|
|
|
|
|
<button style={s.refreshBtn} className="toolbar-btn" onClick={load}>↻ Refresh</button>
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-06 14:11:46 -06:00
|
|
|
|
</div>
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={s.statsRow} className="dashboard-stats">
|
2026-03-11 00:11:42 -05:00
|
|
|
|
{/* Total Employees — clicking shows all */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={cardStyle(FILTER_TOTAL)}
|
|
|
|
|
|
className="dashboard-stat-card"
|
|
|
|
|
|
onClick={() => handleBadgeClick(FILTER_TOTAL)}
|
|
|
|
|
|
title="Click to show all employees"
|
|
|
|
|
|
>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={s.statNum} className="stat-num">{employees.length}</div>
|
|
|
|
|
|
<div style={s.statLbl} className="stat-lbl">Total Employees</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
{activeFilter === FILTER_TOTAL && <div style={s.filterBadge}>▼ Showing All</div>}
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Elite Standing: 0–4 pts */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={cardStyle(FILTER_ELITE, { borderTop: '3px solid #28a745' })}
|
|
|
|
|
|
className="dashboard-stat-card"
|
|
|
|
|
|
onClick={() => handleBadgeClick(FILTER_ELITE)}
|
|
|
|
|
|
title="Click to filter: Elite Standing (0–4 pts)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ ...s.statNum, color: '#6ee7b7' }} className="stat-num">{eliteCount}</div>
|
|
|
|
|
|
<div style={s.statLbl} className="stat-lbl">Elite Standing (0–4 pts)</div>
|
|
|
|
|
|
{activeFilter === FILTER_ELITE && <div style={s.filterBadge}>▼ Filtered</div>}
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
|
|
|
|
|
|
{/* With Active Points */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={cardStyle(FILTER_ACTIVE, { borderTop: '3px solid #d4af37' })}
|
|
|
|
|
|
className="dashboard-stat-card"
|
|
|
|
|
|
onClick={() => handleBadgeClick(FILTER_ACTIVE)}
|
|
|
|
|
|
title="Click to filter: employees with active points"
|
|
|
|
|
|
>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={{ ...s.statNum, color: '#ffd666' }} className="stat-num">{activeCount}</div>
|
|
|
|
|
|
<div style={s.statLbl} className="stat-lbl">With Active Points</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
{activeFilter === FILTER_ACTIVE && <div style={s.filterBadge}>▼ Filtered</div>}
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
|
|
|
|
|
|
{/* At Risk */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={cardStyle(FILTER_AT_RISK, { borderTop: '3px solid #ffb020' })}
|
|
|
|
|
|
className="dashboard-stat-card"
|
|
|
|
|
|
onClick={() => handleBadgeClick(FILTER_AT_RISK)}
|
|
|
|
|
|
title={`Click to filter: at risk (≤${AT_RISK_THRESHOLD} pts to next tier)`}
|
|
|
|
|
|
>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={{ ...s.statNum, color: '#ffdf8a' }} className="stat-num">{atRiskCount}</div>
|
|
|
|
|
|
<div style={s.statLbl} className="stat-lbl">At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
{activeFilter === FILTER_AT_RISK && <div style={s.filterBadge}>▼ Filtered</div>}
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-11 00:11:42 -05:00
|
|
|
|
|
|
|
|
|
|
{/* Highest Score — display only, no filter */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{ ...s.statCard, borderTop: '3px solid #c0392b', cursor: 'default' }}
|
|
|
|
|
|
className="dashboard-stat-card"
|
|
|
|
|
|
>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
<div style={{ ...s.statNum, color: '#ff8a80' }} className="stat-num">{maxPoints}</div>
|
|
|
|
|
|
<div style={s.statLbl} className="stat-lbl">Highest Active Score</div>
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</div>
|
2026-03-06 14:11:46 -06:00
|
|
|
|
</div>
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 17:43:14 -06:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading…</p>
|
2026-03-08 22:05:52 -05:00
|
|
|
|
) : isMobile ? (
|
|
|
|
|
|
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
|
2026-03-06 17:43:14 -06:00
|
|
|
|
) : (
|
|
|
|
|
|
<table style={s.table}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th style={s.th}>#</th>
|
|
|
|
|
|
<th style={s.th}>Employee</th>
|
|
|
|
|
|
<th style={s.th}>Department</th>
|
|
|
|
|
|
<th style={s.th}>Supervisor</th>
|
|
|
|
|
|
<th style={s.th}>Tier / Standing</th>
|
|
|
|
|
|
<th style={s.th}>Active Points</th>
|
|
|
|
|
|
<th style={s.th}>90-Day Violations</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{filtered.length === 0 && (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colSpan={7} style={{ ...s.td, textAlign: 'center', ...s.zeroRow }}>
|
|
|
|
|
|
No employees found.
|
2026-03-06 14:11:46 -06:00
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
2026-03-06 17:43:14 -06:00
|
|
|
|
)}
|
|
|
|
|
|
{filtered.map((emp, i) => {
|
|
|
|
|
|
const risk = isAtRisk(emp.active_points);
|
|
|
|
|
|
const tier = getTier(emp.active_points);
|
|
|
|
|
|
const boundary = nextTierBoundary(emp.active_points);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr
|
|
|
|
|
|
key={emp.id}
|
|
|
|
|
|
style={{ background: risk ? '#181200' : i % 2 === 0 ? '#111217' : '#151622' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<td style={{ ...s.td, color: '#77798a', fontSize: '12px' }}>{i + 1}</td>
|
|
|
|
|
|
<td style={s.td}>
|
|
|
|
|
|
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>
|
|
|
|
|
|
{emp.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{risk && (
|
|
|
|
|
|
<span style={s.atRiskBadge}>
|
|
|
|
|
|
⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.department || '—'}</td>
|
|
|
|
|
|
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.supervisor || '—'}</td>
|
|
|
|
|
|
<td style={s.td}><CpasBadge points={emp.active_points} /></td>
|
|
|
|
|
|
<td style={{ ...s.td, fontWeight: 700, color: tier.color, fontSize: '16px' }}>
|
|
|
|
|
|
{emp.active_points}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.violation_count}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-06 12:53:40 -06:00
|
|
|
|
|
2026-03-06 14:11:46 -06:00
|
|
|
|
{selectedId && (
|
|
|
|
|
|
<EmployeeModal
|
|
|
|
|
|
employeeId={selectedId}
|
|
|
|
|
|
onClose={() => { setSelectedId(null); load(); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-07 09:26:33 -06:00
|
|
|
|
{showAudit && <AuditLog onClose={() => setShowAudit(false)} />}
|
2026-03-06 17:43:14 -06:00
|
|
|
|
</>
|
2026-03-06 14:11:46 -06:00
|
|
|
|
);
|
2026-03-06 12:53:40 -06:00
|
|
|
|
}
|