Upload files to "client/src/components"
This commit is contained in:
@@ -3,171 +3,167 @@ import axios from 'axios';
|
|||||||
import CpasBadge, { getTier } from './CpasBadge';
|
import CpasBadge, { getTier } from './CpasBadge';
|
||||||
import EmployeeModal from './EmployeeModal';
|
import EmployeeModal from './EmployeeModal';
|
||||||
|
|
||||||
const AT_RISK_THRESHOLD = 2; // points within next tier boundary
|
const AT_RISK_THRESHOLD = 2;
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
{ min: 0, max: 4 },
|
{ min: 0, max: 4 },
|
||||||
{ min: 5, max: 9 },
|
{ min: 5, max: 9 },
|
||||||
{ min: 10, max: 14 },
|
{ min: 10, max: 14 },
|
||||||
{ min: 15, max: 19 },
|
{ min: 15, max: 19 },
|
||||||
{ min: 20, max: 24 },
|
{ min: 20, max: 24 },
|
||||||
{ min: 25, max: 29 },
|
{ min: 25, max: 29 },
|
||||||
{ min: 30, max: 999},
|
{ min: 30, max: 999},
|
||||||
];
|
];
|
||||||
|
|
||||||
function nextTierBoundary(points) {
|
function nextTierBoundary(points) {
|
||||||
for (const t of TIERS) {
|
for (const t of TIERS) {
|
||||||
if (points >= t.min && points <= t.max && t.max < 999)
|
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
|
||||||
return t.max + 1;
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAtRisk(points) {
|
function isAtRisk(points) {
|
||||||
const boundary = nextTierBoundary(points);
|
const boundary = nextTierBoundary(points);
|
||||||
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = {
|
const s = {
|
||||||
wrap: { padding: '40px' },
|
wrap: { padding: '32px 40px', color: '#f8f9fa' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
|
||||||
title: { fontSize: '24px', fontWeight: 700, color: '#2c3e50' },
|
title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' },
|
||||||
subtitle: { fontSize: '13px', color: '#888', marginTop: '3px' },
|
subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' },
|
||||||
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
|
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
|
||||||
statCard: { flex: '1', minWidth: '140px', background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: '8px', padding: '16px', textAlign: 'center' },
|
statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #30313f', borderRadius: '8px', padding: '16px', textAlign: 'center' },
|
||||||
statNum: { fontSize: '28px', fontWeight: 800, color: '#2c3e50' },
|
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
|
||||||
statLbl: { fontSize: '11px', color: '#888', marginTop: '4px' },
|
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
|
||||||
search: { padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', width: '260px' },
|
search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' },
|
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: '#34495e', color: 'white', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
|
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 #f0f0f0', fontSize: '13px', verticalAlign: 'middle' },
|
td: { padding: '11px 14px', borderBottom: '1px solid #1c1d29', fontSize: '13px', verticalAlign: 'middle', color: '#f8f9fa' },
|
||||||
nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#667eea', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' },
|
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: '#fff3cd', color: '#856404', border: '1px solid #ffc107', verticalAlign: 'middle' },
|
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: '#aaa', fontStyle: 'italic', fontSize: '12px' },
|
zeroRow: { color: '#77798a', fontStyle: 'italic', fontSize: '12px' },
|
||||||
refreshBtn:{ padding: '9px 18px', background: '#667eea', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
refreshBtn:{ padding: '9px 18px', background: '#d4af37', color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [employees, setEmployees] = useState([]);
|
const [employees, setEmployees] = useState([]);
|
||||||
const [filtered, setFiltered] = useState([]);
|
const [filtered, setFiltered] = useState([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId,setSelectedId] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios.get('/api/dashboard')
|
axios.get('/api/dashboard')
|
||||||
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
setFiltered(employees.filter(e =>
|
setFiltered(employees.filter(e =>
|
||||||
e.name.toLowerCase().includes(q) ||
|
e.name.toLowerCase().includes(q) ||
|
||||||
(e.department || '').toLowerCase().includes(q) ||
|
(e.department || '').toLowerCase().includes(q) ||
|
||||||
(e.supervisor || '').toLowerCase().includes(q)
|
(e.supervisor || '').toLowerCase().includes(q)
|
||||||
));
|
));
|
||||||
}, [search, employees]);
|
}, [search, employees]);
|
||||||
|
|
||||||
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
||||||
const activeCount = employees.filter(e => e.active_points > 0).length;
|
const activeCount = employees.filter(e => e.active_points > 0).length;
|
||||||
const cleanCount = employees.filter(e => e.active_points === 0).length;
|
const cleanCount = employees.filter(e => e.active_points === 0).length;
|
||||||
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.wrap}>
|
<div style={s.wrap}>
|
||||||
<div style={s.header}>
|
<div style={s.header}>
|
||||||
<div>
|
<div>
|
||||||
<div style={s.title}>Company Dashboard</div>
|
<div style={s.title}>Company Dashboard</div>
|
||||||
<div style={s.subtitle}>Click any employee name to view their full profile</div>
|
<div style={s.subtitle}>Click any employee name to view their full profile</div>
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<input style={s.search} placeholder="Search name, dept, supervisor…" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
<button style={s.refreshBtn} onClick={load}>↻ Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Stat cards ───────────────────────────────────────── */}
|
|
||||||
<div style={s.statsRow}>
|
|
||||||
<div style={s.statCard}>
|
|
||||||
<div style={s.statNum}>{employees.length}</div>
|
|
||||||
<div style={s.statLbl}>Total Employees</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
|
|
||||||
<div style={{ ...s.statNum, color: '#28a745' }}>{cleanCount}</div>
|
|
||||||
<div style={s.statLbl}>Elite Standing (0 pts)</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...s.statCard, borderTop: '3px solid #856404' }}>
|
|
||||||
<div style={{ ...s.statNum, color: '#856404' }}>{activeCount}</div>
|
|
||||||
<div style={s.statLbl}>With Active Points</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...s.statCard, borderTop: '3px solid #ffc107' }}>
|
|
||||||
<div style={{ ...s.statNum, color: '#856404' }}>{atRiskCount}</div>
|
|
||||||
<div style={s.statLbl}>At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
|
|
||||||
<div style={{ ...s.statNum, color: '#c0392b' }}>{maxPoints}</div>
|
|
||||||
<div style={s.statLbl}>Highest Active Score</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Scoreboard table ─────────────────────────────────── */}
|
|
||||||
{loading ? (
|
|
||||||
<p style={{ color: '#aaa', textAlign: 'center', padding: '40px' }}>Loading…</p>
|
|
||||||
) : (
|
|
||||||
<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.</td></tr>
|
|
||||||
)}
|
|
||||||
{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 ? '#fffdf0' : i % 2 === 0 ? 'white' : '#fafafa' }}>
|
|
||||||
<td style={{ ...s.td, color: '#aaa', 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: '#666' }}>{emp.department || '—'}</td>
|
|
||||||
<td style={{ ...s.td, color: '#666' }}>{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: '#666' }}>{emp.violation_count}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Employee profile modal ───────────────────────────── */}
|
|
||||||
{selectedId && (
|
|
||||||
<EmployeeModal
|
|
||||||
employeeId={selectedId}
|
|
||||||
onClose={() => { setSelectedId(null); load(); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
|
<input style={s.search} placeholder="Search name, dept, supervisor…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
<button style={s.refreshBtn} onClick={load}>↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.statsRow}>
|
||||||
|
<div style={s.statCard}>
|
||||||
|
<div style={s.statNum}>{employees.length}</div>
|
||||||
|
<div style={s.statLbl}>Total Employees</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#6ee7b7' }}>{cleanCount}</div>
|
||||||
|
<div style={s.statLbl}>Elite Standing (0 pts)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #d4af37' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#ffd666' }}>{activeCount}</div>
|
||||||
|
<div style={s.statLbl}>With Active Points</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #ffb020' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#ffdf8a' }}>{atRiskCount}</div>
|
||||||
|
<div style={s.statLbl}>At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#ff8a80' }}>{maxPoints}</div>
|
||||||
|
<div style={s.statLbl}>Highest Active Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<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.</td></tr>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedId && (
|
||||||
|
<EmployeeModal
|
||||||
|
employeeId={selectedId}
|
||||||
|
onClose={() => { setSelectedId(null); load(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user