Fix math logic for timeline
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CPAS Violation Tracker</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
#root { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cpas-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sha": "dev",
|
||||
"shortSha": "dev",
|
||||
"buildTime": null
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ViolationForm from './components/ViolationForm';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ReadmeModal from './components/ReadmeModal';
|
||||
import ToastProvider from './components/ToastProvider';
|
||||
import './styles/mobile.css';
|
||||
|
||||
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
|
||||
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
|
||||
|
||||
function elapsed(from) {
|
||||
const totalSec = Math.floor((Date.now() - from.getTime()) / 1000);
|
||||
const d = Math.floor(totalSec / 86400);
|
||||
const h = Math.floor((totalSec % 86400) / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
|
||||
}
|
||||
|
||||
function DevTicker() {
|
||||
const [tick, setTick] = useState(() => elapsed(PROJECT_START));
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(elapsed(PROJECT_START)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<span title="Time since first commit" style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span style={{
|
||||
width: '7px', height: '7px', borderRadius: '50%',
|
||||
background: '#22c55e', display: 'inline-block',
|
||||
animation: 'cpas-pulse 1.4s ease-in-out infinite',
|
||||
}} />
|
||||
{tick}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ verticalAlign: 'middle' }}>
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppFooter({ version }) {
|
||||
const year = new Date().getFullYear();
|
||||
const sha = version?.shortSha || null;
|
||||
const built = version?.buildTime
|
||||
? new Date(version.buildTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes cpas-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
|
||||
/* Mobile-specific footer adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<footer style={sf.footer} className="footer-content">
|
||||
<span style={sf.copy}>© {year} Jason Stedwell</span>
|
||||
<span style={sf.sep}>·</span>
|
||||
<DevTicker />
|
||||
<span style={sf.sep}>·</span>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer" style={sf.link}>
|
||||
<GiteaIcon /> cpas
|
||||
</a>
|
||||
{sha && sha !== 'dev' && (
|
||||
<>
|
||||
<span style={sf.sep}>·</span>
|
||||
<a
|
||||
href={`${REPO_URL}/commit/${version.sha}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={sf.link}
|
||||
title={built ? `Built ${built}` : 'View commit'}
|
||||
>
|
||||
{sha}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: '📊 Dashboard' },
|
||||
{ id: 'violation', label: '+ New Violation' },
|
||||
];
|
||||
|
||||
// Responsive utility hook
|
||||
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;
|
||||
}
|
||||
|
||||
const s = {
|
||||
app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' },
|
||||
nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' },
|
||||
logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' },
|
||||
logoImg: { height: '28px', marginRight: '10px' },
|
||||
logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' },
|
||||
tab: (active) => ({
|
||||
padding: '18px 22px',
|
||||
color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)',
|
||||
borderBottom: active ? '3px solid #d4af37' : '3px solid transparent',
|
||||
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
||||
background: 'none', border: 'none',
|
||||
}),
|
||||
docsBtn: {
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: '1px solid #2a2b3a',
|
||||
color: '#9ca0b8',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 14px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
main: { flex: 1 },
|
||||
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
||||
};
|
||||
|
||||
// Mobile-responsive style overrides
|
||||
const mobileStyles = `
|
||||
@media (max-width: 768px) {
|
||||
.app-nav {
|
||||
padding: 0 16px !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo-wrap {
|
||||
margin-right: 0 !important;
|
||||
padding: 12px 0 !important;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #1a1b22;
|
||||
}
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.docs-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
padding: 4px 10px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.docs-btn span:first-child {
|
||||
display: none;
|
||||
}
|
||||
.main-card {
|
||||
margin: 12px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logo-text {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.logo-img {
|
||||
height: 24px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sf = {
|
||||
footer: {
|
||||
borderTop: '1px solid #1a1b22',
|
||||
padding: '12px 40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '11px',
|
||||
color: 'rgba(248,249,250,0.35)',
|
||||
background: '#000',
|
||||
flexShrink: 0,
|
||||
},
|
||||
copy: { color: 'rgba(248,249,250,0.35)' },
|
||||
sep: { color: 'rgba(248,249,250,0.15)' },
|
||||
link: {
|
||||
color: 'rgba(248,249,250,0.35)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'color 0.15s',
|
||||
},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [showReadme, setShowReadme] = useState(false);
|
||||
const [version, setVersion] = useState(null);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(v => { if (v) setVersion(v); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<style>{mobileStyles}</style>
|
||||
<div style={s.app}>
|
||||
<nav style={s.nav} className="app-nav">
|
||||
<div style={s.logoWrap} className="logo-wrap">
|
||||
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} className="logo-img" />
|
||||
<div style={s.logoText} className="logo-text">CPAS Tracker</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-tabs">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
|
||||
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
|
||||
<span>?</span> Docs
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div style={s.main}>
|
||||
<div style={s.card} className="main-card">
|
||||
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppFooter version={version} />
|
||||
|
||||
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const FIELD_LABELS = {
|
||||
incident_time: 'Incident Time',
|
||||
location: 'Location / Context',
|
||||
details: 'Incident Notes',
|
||||
submitted_by: 'Submitted By',
|
||||
witness_name: 'Witness / Documenting Officer',
|
||||
};
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
|
||||
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
modal: {
|
||||
background: '#111217', color: '#f8f9fa', width: '520px', maxWidth: '95vw',
|
||||
maxHeight: '90vh', overflowY: 'auto',
|
||||
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
|
||||
border: '1px solid #222',
|
||||
},
|
||||
header: {
|
||||
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
||||
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #222', position: 'sticky', top: 0, zIndex: 10,
|
||||
},
|
||||
headerLeft: {},
|
||||
title: { fontSize: '15px', fontWeight: 700 },
|
||||
subtitle: { fontSize: '11px', color: '#9ca0b8', marginTop: '2px' },
|
||||
closeBtn: {
|
||||
background: 'none', border: 'none', color: 'white', fontSize: '20px',
|
||||
cursor: 'pointer', lineHeight: 1,
|
||||
},
|
||||
body: { padding: '22px' },
|
||||
notice: {
|
||||
background: '#0e1a30', border: '1px solid #1e3a5f', borderRadius: '6px',
|
||||
padding: '10px 14px', fontSize: '12px', color: '#7eb8f7', marginBottom: '18px',
|
||||
},
|
||||
label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' },
|
||||
input: {
|
||||
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
|
||||
outline: 'none', boxSizing: 'border-box', minHeight: '80px', resize: 'vertical',
|
||||
},
|
||||
divider: { borderTop: '1px solid #1c1d29', margin: '16px 0' },
|
||||
sectionTitle: {
|
||||
fontSize: '11px', fontWeight: 700, color: '#9ca0b8', textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px', marginBottom: '12px',
|
||||
},
|
||||
amendRow: {
|
||||
background: '#0d0e14', border: '1px solid #1c1d29', borderRadius: '6px',
|
||||
padding: '10px 12px', marginBottom: '8px', fontSize: '12px',
|
||||
},
|
||||
amendField: { fontWeight: 700, color: '#c0c2d6', marginBottom: '4px' },
|
||||
amendOld: { color: '#ff7070', textDecoration: 'line-through', marginRight: '6px' },
|
||||
amendNew: { color: '#9ef7c1' },
|
||||
amendMeta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
|
||||
row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' },
|
||||
btn: (color, bg) => ({
|
||||
padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px',
|
||||
cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none',
|
||||
}),
|
||||
error: {
|
||||
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
|
||||
padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px',
|
||||
},
|
||||
};
|
||||
|
||||
function fmtDt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function AmendViolationModal({ violation, onClose, onSaved }) {
|
||||
const [fields, setFields] = useState({
|
||||
incident_time: violation.incident_time || '',
|
||||
location: violation.location || '',
|
||||
details: violation.details || '',
|
||||
submitted_by: violation.submitted_by || '',
|
||||
witness_name: violation.witness_name || '',
|
||||
});
|
||||
const [changedBy, setChangedBy] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [amendments, setAmendments] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get(`/api/violations/${violation.id}/amendments`)
|
||||
.then(r => setAmendments(r.data))
|
||||
.catch(() => {});
|
||||
}, [violation.id]);
|
||||
|
||||
const hasChanges = Object.entries(fields).some(
|
||||
([k, v]) => v !== (violation[k] || '')
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('');
|
||||
setSaving(true);
|
||||
try {
|
||||
// Only send fields that actually changed
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(fields).filter(([k, v]) => v !== (violation[k] || ''))
|
||||
);
|
||||
await axios.patch(`/api/violations/${violation.id}/amend`, { ...patch, changed_by: changedBy || null });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e.response?.data?.error || 'Failed to save amendment');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (field, value) => setFields(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={s.modal}>
|
||||
<div style={s.header}>
|
||||
<div style={s.headerLeft}>
|
||||
<div style={s.title}>Amend Violation</div>
|
||||
<div style={s.subtitle}>
|
||||
CPAS-{String(violation.id).padStart(5, '0')} · {violation.violation_name} · {violation.incident_date}
|
||||
</div>
|
||||
</div>
|
||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
<div style={s.notice}>
|
||||
Only non-scoring fields can be amended. Point values, violation type, and incident date
|
||||
are immutable — delete and re-submit if those need to change.
|
||||
</div>
|
||||
|
||||
{error && <div style={s.error}>{error}</div>}
|
||||
|
||||
{Object.entries(FIELD_LABELS).map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<div style={s.label}>{label}</div>
|
||||
{field === 'details' ? (
|
||||
<textarea
|
||||
style={s.textarea}
|
||||
value={fields[field]}
|
||||
onChange={e => set(field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={s.input}
|
||||
value={fields[field]}
|
||||
onChange={e => set(field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={s.label}>Your Name (recorded in amendment log)</div>
|
||||
<input
|
||||
style={s.input}
|
||||
value={changedBy}
|
||||
onChange={e => setChangedBy(e.target.value)}
|
||||
placeholder="Optional but recommended"
|
||||
/>
|
||||
|
||||
<div style={s.row}>
|
||||
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
style={s.btn('#fff', hasChanges ? '#667eea' : '#333')}
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Amendment'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{amendments.length > 0 && (
|
||||
<>
|
||||
<div style={s.divider} />
|
||||
<div style={s.sectionTitle}>Amendment History ({amendments.length})</div>
|
||||
{amendments.map(a => (
|
||||
<div key={a.id} style={s.amendRow}>
|
||||
<div style={s.amendField}>{FIELD_LABELS[a.field_name] || a.field_name}</div>
|
||||
<div>
|
||||
<span style={s.amendOld}>{a.old_value || '(empty)'}</span>
|
||||
<span style={{ color: '#555', marginRight: '6px' }}>→</span>
|
||||
<span style={s.amendNew}>{a.new_value || '(empty)'}</span>
|
||||
</div>
|
||||
<div style={s.amendMeta}>
|
||||
{a.changed_by ? `by ${a.changed_by} · ` : ''}{fmtDt(a.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const ACTION_COLORS = {
|
||||
employee_created: '#667eea',
|
||||
employee_edited: '#9b8af8',
|
||||
employee_merged: '#f0a500',
|
||||
violation_created: '#28a745',
|
||||
violation_amended: '#4db6ac',
|
||||
violation_negated: '#ffc107',
|
||||
violation_restored:'#17a2b8',
|
||||
violation_deleted: '#dc3545',
|
||||
};
|
||||
|
||||
const ACTION_LABELS = {
|
||||
employee_created: 'Employee Created',
|
||||
employee_edited: 'Employee Edited',
|
||||
employee_merged: 'Employee Merged',
|
||||
violation_created: 'Violation Logged',
|
||||
violation_amended: 'Violation Amended',
|
||||
violation_negated: 'Violation Negated',
|
||||
violation_restored:'Violation Restored',
|
||||
violation_deleted: 'Violation Deleted',
|
||||
};
|
||||
|
||||
const ENTITY_LABELS = {
|
||||
employee: 'Employee',
|
||||
violation: 'Violation',
|
||||
};
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||
zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
||||
},
|
||||
panel: {
|
||||
background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw',
|
||||
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
||||
padding: '22px 26px', position: 'sticky', top: 0, zIndex: 10,
|
||||
borderBottom: '1px solid #222',
|
||||
},
|
||||
headerRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
|
||||
title: { fontSize: '17px', fontWeight: 700 },
|
||||
subtitle: { fontSize: '12px', color: '#9ca0b8', marginTop: '3px' },
|
||||
closeBtn: {
|
||||
background: 'none', border: 'none', color: 'white', fontSize: '22px',
|
||||
cursor: 'pointer', lineHeight: 1,
|
||||
},
|
||||
filters: {
|
||||
padding: '14px 26px', borderBottom: '1px solid #1c1d29',
|
||||
display: 'flex', gap: '10px', flexWrap: 'wrap',
|
||||
},
|
||||
select: {
|
||||
background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#f8f9fa', padding: '7px 12px', fontSize: '12px', outline: 'none',
|
||||
},
|
||||
body: { padding: '16px 26px', flex: 1 },
|
||||
entry: {
|
||||
borderBottom: '1px solid #1c1d29', padding: '12px 0',
|
||||
display: 'flex', gap: '12px', alignItems: 'flex-start',
|
||||
},
|
||||
dot: (action) => ({
|
||||
width: '8px', height: '8px', borderRadius: '50%', marginTop: '5px', flexShrink: 0,
|
||||
background: ACTION_COLORS[action] || '#555',
|
||||
}),
|
||||
entryMain: { flex: 1, minWidth: 0 },
|
||||
actionBadge: (action) => ({
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
|
||||
fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', marginRight: '6px',
|
||||
background: (ACTION_COLORS[action] || '#555') + '22',
|
||||
color: ACTION_COLORS[action] || '#aaa',
|
||||
border: `1px solid ${(ACTION_COLORS[action] || '#555')}44`,
|
||||
}),
|
||||
entityRef: { fontSize: '11px', color: '#9ca0b8' },
|
||||
details: { fontSize: '11px', color: '#667', marginTop: '4px', fontFamily: 'monospace', wordBreak: 'break-all' },
|
||||
meta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
|
||||
empty: { textAlign: 'center', color: '#555a7a', padding: '60px 0', fontSize: '13px' },
|
||||
loadMore: {
|
||||
width: '100%', background: 'none', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#9ca0b8', padding: '10px', cursor: 'pointer', fontSize: '12px', marginTop: '16px',
|
||||
},
|
||||
};
|
||||
|
||||
function fmtDt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetails(detailsStr) {
|
||||
if (!detailsStr) return null;
|
||||
try {
|
||||
const obj = JSON.parse(detailsStr);
|
||||
return JSON.stringify(obj, null, 0)
|
||||
.replace(/^\{/, '').replace(/\}$/, '').replace(/","/g, ' ');
|
||||
} catch {
|
||||
return detailsStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuditLog({ onClose }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const LIMIT = 50;
|
||||
|
||||
const load = useCallback((reset = false) => {
|
||||
setLoading(true);
|
||||
const o = reset ? 0 : offset;
|
||||
const params = { limit: LIMIT, offset: o };
|
||||
if (filterType) params.entity_type = filterType;
|
||||
if (filterAction) params.action = filterAction; // future: server-side action filter
|
||||
axios.get('/api/audit', { params })
|
||||
.then(r => {
|
||||
const data = r.data;
|
||||
// Client-side action filter (cheap enough at this scale)
|
||||
const filtered = filterAction ? data.filter(e => e.action === filterAction) : data;
|
||||
setEntries(prev => reset ? filtered : [...prev, ...filtered]);
|
||||
setHasMore(data.length === LIMIT);
|
||||
setOffset(o + LIMIT);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [offset, filterType, filterAction]);
|
||||
|
||||
useEffect(() => { load(true); }, [filterType, filterAction]); // eslint-disable-line
|
||||
|
||||
const handleOverlay = e => { if (e.target === e.currentTarget) onClose(); };
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={handleOverlay}>
|
||||
<div style={s.panel} onClick={e => e.stopPropagation()}>
|
||||
<div style={s.header}>
|
||||
<div style={s.headerRow}>
|
||||
<div>
|
||||
<div style={s.title}>Audit Log</div>
|
||||
<div style={s.subtitle}>All system write actions — append-only</div>
|
||||
</div>
|
||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.filters}>
|
||||
<select style={s.select} value={filterType} onChange={e => { setFilterType(e.target.value); setOffset(0); }}>
|
||||
<option value="">All entity types</option>
|
||||
{Object.entries(ENTITY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<select style={s.select} value={filterAction} onChange={e => { setFilterAction(e.target.value); setOffset(0); }}>
|
||||
<option value="">All actions</option>
|
||||
{Object.entries(ACTION_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
{loading && entries.length === 0 ? (
|
||||
<div style={s.empty}>Loading…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={s.empty}>No audit entries found.</div>
|
||||
) : (
|
||||
entries.map(e => (
|
||||
<div key={e.id} style={s.entry}>
|
||||
<div style={s.dot(e.action)} />
|
||||
<div style={s.entryMain}>
|
||||
<div>
|
||||
<span style={s.actionBadge(e.action)}>
|
||||
{ACTION_LABELS[e.action] || e.action}
|
||||
</span>
|
||||
<span style={s.entityRef}>
|
||||
{ENTITY_LABELS[e.entity_type] || e.entity_type}
|
||||
{e.entity_id ? ` #${e.entity_id}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{e.details && (
|
||||
<div style={s.details}>{renderDetails(e.details)}</div>
|
||||
)}
|
||||
<div style={s.meta}>
|
||||
{e.performed_by ? `by ${e.performed_by} · ` : ''}{fmtDt(e.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<button style={s.loadMore} onClick={() => load(false)}>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
const TIERS = [
|
||||
{ min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745', bg: '#d4edda' },
|
||||
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404', bg: '#fff3cd' },
|
||||
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f', bg: '#f8d7da' },
|
||||
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f', bg: '#f8d7da' },
|
||||
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#721c24', bg: '#f5c6cb' },
|
||||
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#721c24', bg: '#f5c6cb' },
|
||||
{ min: 30, max: 999,label: 'Tier 6 — Separation', color: '#fff', bg: '#721c24' },
|
||||
];
|
||||
|
||||
export function getTier(points) {
|
||||
return TIERS.find(t => points >= t.min && points <= t.max) || TIERS[0];
|
||||
}
|
||||
|
||||
export function getNextTier(points) {
|
||||
const idx = TIERS.findIndex(t => points >= t.min && points <= t.max);
|
||||
return idx >= 0 && idx < TIERS.length - 1 ? TIERS[idx + 1] : null;
|
||||
}
|
||||
|
||||
export default function CpasBadge({ points }) {
|
||||
const tier = getTier(points);
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: tier.color,
|
||||
background: tier.bg,
|
||||
border: `1px solid ${tier.color}`,
|
||||
}}>
|
||||
{points} pts — {tier.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import CpasBadge, { getTier } from './CpasBadge';
|
||||
import EmployeeModal from './EmployeeModal';
|
||||
import AuditLog from './AuditLog';
|
||||
import DashboardMobile from './DashboardMobile';
|
||||
|
||||
const AT_RISK_THRESHOLD = 2;
|
||||
|
||||
const TIERS = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function nextTierBoundary(points) {
|
||||
for (const t of TIERS) {
|
||||
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAtRisk(points) {
|
||||
const boundary = nextTierBoundary(points);
|
||||
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
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;
|
||||
}
|
||||
|
||||
// Filter keys
|
||||
const FILTER_NONE = null;
|
||||
const FILTER_TOTAL = 'total';
|
||||
const FILTER_ELITE = 'elite';
|
||||
const FILTER_ACTIVE = 'active';
|
||||
const FILTER_AT_RISK = 'at_risk';
|
||||
|
||||
const s = {
|
||||
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' },
|
||||
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' },
|
||||
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
|
||||
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
|
||||
filterBadge: { fontSize: '10px', color: '#d4af37', marginTop: '4px', fontWeight: 600 },
|
||||
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' },
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Dashboard() {
|
||||
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);
|
||||
const [activeFilter, setActiveFilter] = useState(FILTER_NONE);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
axios.get('/api/dashboard')
|
||||
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Apply search + badge filter together
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase();
|
||||
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]);
|
||||
|
||||
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
||||
const activeCount = employees.filter(e => e.active_points > 0).length;
|
||||
// Elite Standing: 0–4 pts (Tier 0-1)
|
||||
const eliteCount = employees.filter(e => e.active_points >= 0 && e.active_points <= 4).length;
|
||||
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{mobileStyles}</style>
|
||||
<div style={s.wrap} className="dashboard-wrap">
|
||||
<div style={s.header} className="dashboard-header">
|
||||
<div>
|
||||
<div style={s.title} className="dashboard-title">Company Dashboard</div>
|
||||
<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>
|
||||
</div>
|
||||
<div style={s.toolbarRight} className="toolbar-right">
|
||||
<input
|
||||
style={s.search}
|
||||
className="search-input"
|
||||
placeholder="Search name, dept, supervisor…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<button style={s.auditBtn} className="toolbar-btn" onClick={() => setShowAudit(true)}>📋 Audit Log</button>
|
||||
<button style={s.refreshBtn} className="toolbar-btn" onClick={load}>↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.statsRow} className="dashboard-stats">
|
||||
{/* Total Employees — clicking shows all */}
|
||||
<div
|
||||
style={cardStyle(FILTER_TOTAL)}
|
||||
className="dashboard-stat-card"
|
||||
onClick={() => handleBadgeClick(FILTER_TOTAL)}
|
||||
title="Click to show all employees"
|
||||
>
|
||||
<div style={s.statNum} className="stat-num">{employees.length}</div>
|
||||
<div style={s.statLbl} className="stat-lbl">Total Employees</div>
|
||||
{activeFilter === FILTER_TOTAL && <div style={s.filterBadge}>▼ Showing All</div>}
|
||||
</div>
|
||||
|
||||
{/* 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>}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<div style={{ ...s.statNum, color: '#ffd666' }} className="stat-num">{activeCount}</div>
|
||||
<div style={s.statLbl} className="stat-lbl">With Active Points</div>
|
||||
{activeFilter === FILTER_ACTIVE && <div style={s.filterBadge}>▼ Filtered</div>}
|
||||
</div>
|
||||
|
||||
{/* 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)`}
|
||||
>
|
||||
<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>
|
||||
{activeFilter === FILTER_AT_RISK && <div style={s.filterBadge}>▼ Filtered</div>}
|
||||
</div>
|
||||
|
||||
{/* Highest Score — display only, no filter */}
|
||||
<div
|
||||
style={{ ...s.statCard, borderTop: '3px solid #c0392b', cursor: 'default' }}
|
||||
className="dashboard-stat-card"
|
||||
>
|
||||
<div style={{ ...s.statNum, color: '#ff8a80' }} className="stat-num">{maxPoints}</div>
|
||||
<div style={s.statLbl} className="stat-lbl">Highest Active Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading…</p>
|
||||
) : isMobile ? (
|
||||
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedId && (
|
||||
<EmployeeModal
|
||||
employeeId={selectedId}
|
||||
onClose={() => { setSelectedId(null); load(); }}
|
||||
/>
|
||||
)}
|
||||
{showAudit && <AuditLog onClose={() => setShowAudit(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import CpasBadge, { getTier } from './CpasBadge';
|
||||
|
||||
const AT_RISK_THRESHOLD = 2;
|
||||
|
||||
const TIERS = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
function nextTierBoundary(points) {
|
||||
for (const t of TIERS) {
|
||||
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAtRisk(points) {
|
||||
const boundary = nextTierBoundary(points);
|
||||
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
||||
}
|
||||
|
||||
const s = {
|
||||
card: {
|
||||
background: '#181924',
|
||||
border: '1px solid #2a2b3a',
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
marginBottom: '12px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
|
||||
},
|
||||
cardAtRisk: {
|
||||
background: '#181200',
|
||||
border: '1px solid #d4af37',
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
},
|
||||
rowLast: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
label: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#9ca0b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
value: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#f8f9fa',
|
||||
textAlign: 'right',
|
||||
},
|
||||
name: {
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
color: '#d4af37',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline dotted',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
},
|
||||
atRiskBadge: {
|
||||
display: 'inline-block',
|
||||
marginTop: '4px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
background: '#3b2e00',
|
||||
color: '#ffd666',
|
||||
border: '1px solid #d4af37',
|
||||
},
|
||||
points: {
|
||||
fontSize: '28px',
|
||||
fontWeight: 800,
|
||||
textAlign: 'center',
|
||||
margin: '8px 0',
|
||||
},
|
||||
};
|
||||
|
||||
export default function DashboardMobile({ employees, onEmployeeClick }) {
|
||||
if (!employees || employees.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#77798a', fontStyle: 'italic' }}>
|
||||
No employees found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px' }}>
|
||||
{employees.map((emp) => {
|
||||
const risk = isAtRisk(emp.active_points);
|
||||
const tier = getTier(emp.active_points);
|
||||
const boundary = nextTierBoundary(emp.active_points);
|
||||
const cardStyle = risk ? { ...s.card, ...s.cardAtRisk } : s.card;
|
||||
|
||||
return (
|
||||
<div key={emp.id} style={cardStyle}>
|
||||
<button style={s.name} onClick={() => onEmployeeClick(emp.id)}>
|
||||
{emp.name}
|
||||
</button>
|
||||
{risk && (
|
||||
<div style={s.atRiskBadge}>
|
||||
⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...s.row, marginTop: '12px' }}>
|
||||
<span style={s.label}>Tier / Standing</span>
|
||||
<span style={s.value}><CpasBadge points={emp.active_points} /></span>
|
||||
</div>
|
||||
|
||||
<div style={s.row}>
|
||||
<span style={s.label}>Active Points</span>
|
||||
<span style={{ ...s.points, color: tier.color }}>{emp.active_points}</span>
|
||||
</div>
|
||||
|
||||
<div style={s.row}>
|
||||
<span style={s.label}>90-Day Violations</span>
|
||||
<span style={s.value}>{emp.violation_count}</span>
|
||||
</div>
|
||||
|
||||
{emp.department && (
|
||||
<div style={s.row}>
|
||||
<span style={s.label}>Department</span>
|
||||
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.department}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emp.supervisor && (
|
||||
<div style={{ ...s.row, ...s.rowLast }}>
|
||||
<span style={s.label}>Supervisor</span>
|
||||
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.supervisor}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { DEPARTMENTS } from '../data/departments';
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
|
||||
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
modal: {
|
||||
background: '#111217', color: '#f8f9fa', width: '480px', maxWidth: '95vw',
|
||||
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
|
||||
border: '1px solid #222', overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
||||
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #222',
|
||||
},
|
||||
title: { fontSize: '15px', fontWeight: 700 },
|
||||
closeBtn: {
|
||||
background: 'none', border: 'none', color: 'white', fontSize: '20px',
|
||||
cursor: 'pointer', lineHeight: 1,
|
||||
},
|
||||
body: { padding: '22px' },
|
||||
tabs: { display: 'flex', gap: '4px', marginBottom: '20px' },
|
||||
tab: (active) => ({
|
||||
flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer', fontSize: '12px',
|
||||
fontWeight: 700, textAlign: 'center', border: '1px solid',
|
||||
background: active ? '#1a1c2e' : 'none',
|
||||
borderColor: active ? '#667eea' : '#2a2b3a',
|
||||
color: active ? '#667eea' : '#777',
|
||||
}),
|
||||
label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' },
|
||||
input: {
|
||||
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
},
|
||||
select: {
|
||||
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
|
||||
outline: 'none', boxSizing: 'border-box',
|
||||
},
|
||||
row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' },
|
||||
btn: (color, bg) => ({
|
||||
padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px',
|
||||
cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none',
|
||||
}),
|
||||
error: {
|
||||
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
|
||||
padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px',
|
||||
},
|
||||
success: {
|
||||
background: '#0a2e1f', border: '1px solid #0f5132', borderRadius: '6px',
|
||||
padding: '10px 12px', fontSize: '12px', color: '#9ef7c1', marginBottom: '14px',
|
||||
},
|
||||
mergeWarning: {
|
||||
background: '#2a1f00', border: '1px solid #7a5000', borderRadius: '6px',
|
||||
padding: '12px', fontSize: '12px', color: '#ffc107', marginBottom: '14px', lineHeight: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
export default function EditEmployeeModal({ employee, onClose, onSaved }) {
|
||||
const [tab, setTab] = useState('edit');
|
||||
|
||||
// Edit state
|
||||
const [name, setName] = useState(employee.name);
|
||||
const [department, setDepartment] = useState(employee.department || '');
|
||||
const [supervisor, setSupervisor] = useState(employee.supervisor || '');
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
// Merge state
|
||||
const [allEmployees, setAllEmployees] = useState([]);
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [mergeError, setMergeError] = useState('');
|
||||
const [mergeResult, setMergeResult] = useState(null);
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'merge') {
|
||||
axios.get('/api/employees').then(r => setAllEmployees(r.data));
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
setEditError('');
|
||||
setEditSaving(true);
|
||||
try {
|
||||
await axios.patch(`/api/employees/${employee.id}`, { name, department, supervisor });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setEditError(e.response?.data?.error || 'Failed to save changes');
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!sourceId) return setMergeError('Select an employee to merge in');
|
||||
setMergeError('');
|
||||
setMerging(true);
|
||||
try {
|
||||
const r = await axios.post(`/api/employees/${employee.id}/merge`, { source_id: parseInt(sourceId) });
|
||||
setMergeResult(r.data);
|
||||
onSaved(); // refresh dashboard / parent list
|
||||
} catch (e) {
|
||||
setMergeError(e.response?.data?.error || 'Merge failed');
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const otherEmployees = allEmployees.filter(e => e.id !== employee.id);
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={s.modal}>
|
||||
<div style={s.header}>
|
||||
<div style={s.title}>Edit Employee</div>
|
||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div style={s.body}>
|
||||
<div style={s.tabs}>
|
||||
<button style={s.tab(tab === 'edit')} onClick={() => setTab('edit')}>Edit Details</button>
|
||||
<button style={s.tab(tab === 'merge')} onClick={() => setTab('merge')}>Merge Duplicate</button>
|
||||
</div>
|
||||
|
||||
{tab === 'edit' && (
|
||||
<>
|
||||
{editError && <div style={s.error}>{editError}</div>}
|
||||
<div style={s.label}>Full Name</div>
|
||||
<input style={s.input} value={name} onChange={e => setName(e.target.value)} />
|
||||
<div style={s.label}>Department</div>
|
||||
<select style={s.select} value={department} onChange={e => setDepartment(e.target.value)}>
|
||||
<option value="">-- Select Department --</option>
|
||||
{DEPARTMENTS.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={s.label}>Supervisor</div>
|
||||
<input style={s.input} value={supervisor} onChange={e => setSupervisor(e.target.value)} placeholder="Optional" />
|
||||
<div style={s.row}>
|
||||
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
|
||||
<button style={s.btn('#fff', '#667eea')} onClick={handleEdit} disabled={editSaving}>
|
||||
{editSaving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'merge' && (
|
||||
<>
|
||||
{mergeResult ? (
|
||||
<div style={s.success}>
|
||||
✓ Merge complete — {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned
|
||||
to <strong>{employee.name}</strong>. The duplicate record has been removed.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={s.mergeWarning}>
|
||||
⚠ This will reassign <strong>all violations</strong> from the selected employee into{' '}
|
||||
<strong>{employee.name}</strong>, then permanently delete the duplicate record.
|
||||
This cannot be undone.
|
||||
</div>
|
||||
{mergeError && <div style={s.error}>{mergeError}</div>}
|
||||
<div style={s.label}>Duplicate to merge into {employee.name}</div>
|
||||
<select style={s.select} value={sourceId} onChange={e => setSourceId(e.target.value)}>
|
||||
<option value="">— select employee —</option>
|
||||
{otherEmployees.map(e => (
|
||||
<option key={e.id} value={e.id}>{e.name}{e.department ? ` (${e.department})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={s.row}>
|
||||
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
|
||||
<button style={s.btn('#fff', '#c0392b')} onClick={handleMerge} disabled={merging || !sourceId}>
|
||||
{merging ? 'Merging…' : 'Merge & Delete Duplicate'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mergeResult && (
|
||||
<div style={s.row}>
|
||||
<button style={s.btn('#fff', '#667eea')} onClick={onClose}>Done</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import CpasBadge, { getTier } from './CpasBadge';
|
||||
import NegateModal from './NegateModal';
|
||||
import EditEmployeeModal from './EditEmployeeModal';
|
||||
import AmendViolationModal from './AmendViolationModal';
|
||||
import ExpirationTimeline from './ExpirationTimeline';
|
||||
import EmployeeNotes from './EmployeeNotes';
|
||||
import { useToast } from './ToastProvider';
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||
zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
||||
},
|
||||
panel: {
|
||||
background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw',
|
||||
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
||||
padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10,
|
||||
borderBottom: '1px solid #222',
|
||||
},
|
||||
headerRow: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' },
|
||||
closeBtn: {
|
||||
float: 'right', background: 'none', border: 'none', color: 'white',
|
||||
fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px',
|
||||
},
|
||||
editEmpBtn: {
|
||||
background: 'none', border: '1px solid #555', color: '#ccc', borderRadius: '5px',
|
||||
padding: '4px 10px', fontSize: '11px', cursor: 'pointer', marginTop: '8px', fontWeight: 600,
|
||||
},
|
||||
body: { padding: '24px 28px', flex: 1 },
|
||||
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
|
||||
scoreCard: {
|
||||
flex: '1', minWidth: '100px', background: '#181924', borderRadius: '8px',
|
||||
padding: '14px', textAlign: 'center', border: '1px solid #2a2b3a',
|
||||
},
|
||||
scoreNum: { fontSize: '26px', fontWeight: 800 },
|
||||
scoreLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '3px' },
|
||||
sectionHd: {
|
||||
fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px', marginBottom: '10px', marginTop: '24px',
|
||||
},
|
||||
table: {
|
||||
width: '100%', borderCollapse: 'collapse', fontSize: '12px', background: '#181924',
|
||||
borderRadius: '6px', overflow: 'hidden', border: '1px solid #2a2b3a',
|
||||
},
|
||||
th: {
|
||||
background: '#050608', padding: '8px 10px', textAlign: 'left', color: '#f8f9fa',
|
||||
fontWeight: 600, fontSize: '11px', textTransform: 'uppercase',
|
||||
},
|
||||
td: {
|
||||
padding: '9px 10px', borderBottom: '1px solid #202231',
|
||||
verticalAlign: 'top', color: '#f8f9fa',
|
||||
},
|
||||
negatedRow: { background: '#151622', color: '#9ca0b8' },
|
||||
actionBtn: (color) => ({
|
||||
background: 'none', border: `1px solid ${color}`, color,
|
||||
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
||||
cursor: 'pointer', marginRight: '4px', fontWeight: 600,
|
||||
}),
|
||||
resTag: {
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
|
||||
fontSize: '10px', fontWeight: 700, background: '#053321',
|
||||
color: '#9ef7c1', border: '1px solid #0f5132',
|
||||
},
|
||||
pdfBtn: {
|
||||
background: 'none', border: '1px solid #d4af37', color: '#ffd666',
|
||||
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
||||
cursor: 'pointer', fontWeight: 600,
|
||||
},
|
||||
amendBtn: {
|
||||
background: 'none', border: '1px solid #4db6ac', color: '#4db6ac',
|
||||
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
||||
cursor: 'pointer', marginRight: '4px', fontWeight: 600,
|
||||
},
|
||||
deleteConfirm: {
|
||||
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
|
||||
padding: '12px', marginTop: '8px', fontSize: '12px', color: '#ffb3b8',
|
||||
},
|
||||
amendBadge: {
|
||||
display: 'inline-block', marginLeft: '4px', padding: '1px 5px', borderRadius: '8px',
|
||||
fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac',
|
||||
border: '1px solid #1a4a4a', verticalAlign: 'middle',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EmployeeModal({ employeeId, onClose }) {
|
||||
const [employee, setEmployee] = useState(null);
|
||||
const [score, setScore] = useState(null);
|
||||
const [violations, setViolations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [negating, setNegating] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [editingEmp, setEditingEmp] = useState(false);
|
||||
const [amending, setAmending] = useState(null); // violation object
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
axios.get('/api/employees'),
|
||||
axios.get(`/api/employees/${employeeId}/score`),
|
||||
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
|
||||
])
|
||||
.then(([empRes, scoreRes, violRes]) => {
|
||||
const emp = empRes.data.find((e) => e.id === employeeId);
|
||||
setEmployee(emp || null);
|
||||
setScore(scoreRes.data);
|
||||
setViolations(violRes.data);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [employeeId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDownloadPdf = async (violId, empName, date) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('PDF downloaded.');
|
||||
} catch (err) {
|
||||
toast.error('PDF generation failed: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHardDelete = async (id) => {
|
||||
try {
|
||||
await axios.delete(`/api/violations/${id}`);
|
||||
toast.success('Violation permanently deleted.');
|
||||
setConfirmDel(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error('Delete failed: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id) => {
|
||||
try {
|
||||
await axios.patch(`/api/violations/${id}/restore`);
|
||||
toast.success('Violation restored to active.');
|
||||
setConfirmDel(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error('Restore failed: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
|
||||
try {
|
||||
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
|
||||
toast.success('Violation negated.');
|
||||
setNegating(null);
|
||||
setConfirmDel(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error('Negate failed: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
const tier = score ? getTier(score.active_points) : null;
|
||||
const active = violations.filter((v) => !v.negated);
|
||||
const negated = violations.filter((v) => v.negated);
|
||||
|
||||
const handleOverlayClick = (e) => { if (e.target === e.currentTarget) onClose(); };
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||
<div style={s.panel} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={s.header}>
|
||||
<div style={s.headerRow}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700 }}>
|
||||
{employee ? employee.name : 'Employee'}
|
||||
</div>
|
||||
{employee && (
|
||||
<div style={{ fontSize: '12px', color: '#b5b5c0', marginTop: '4px' }}>
|
||||
{employee.department} {employee.supervisor && `· Supervisor: ${employee.supervisor}`}
|
||||
</div>
|
||||
)}
|
||||
{employee && (
|
||||
<button style={s.editEmpBtn} onClick={() => setEditingEmp(true)}>
|
||||
✎ Edit Employee
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div style={s.body}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#b5b5c0' }}>Loading…</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Score Cards */}
|
||||
{score && (
|
||||
<div style={s.scoreRow}>
|
||||
<div style={s.scoreCard}>
|
||||
<div style={{ ...s.scoreNum, color: tier?.color || '#f8f9fa' }}>
|
||||
{score.active_points}
|
||||
</div>
|
||||
<div style={s.scoreLbl}>Active Points</div>
|
||||
</div>
|
||||
<div style={s.scoreCard}>
|
||||
<div style={s.scoreNum}>{score.total_violations}</div>
|
||||
<div style={s.scoreLbl}>Total Violations</div>
|
||||
</div>
|
||||
<div style={s.scoreCard}>
|
||||
<div style={s.scoreNum}>{score.negated_count}</div>
|
||||
<div style={s.scoreLbl}>Negated</div>
|
||||
</div>
|
||||
<div style={{ ...s.scoreCard, minWidth: '140px' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}>
|
||||
{tier ? tier.label : '—'}
|
||||
</div>
|
||||
<div style={s.scoreLbl}>Current Tier</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{score && <CpasBadge points={score.active_points} style={{ marginBottom: '20px' }} />}
|
||||
|
||||
{/* ── Employee Notes ── */}
|
||||
{employee && (
|
||||
<EmployeeNotes
|
||||
employeeId={employeeId}
|
||||
initialNotes={employee.notes}
|
||||
onSaved={(notes) => setEmployee(prev => ({ ...prev, notes }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Expiration Timeline ── */}
|
||||
{score && score.active_points > 0 && (
|
||||
<ExpirationTimeline
|
||||
employeeId={employeeId}
|
||||
currentPoints={score.active_points}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Active Violations ── */}
|
||||
<div style={s.sectionHd}>Active Violations</div>
|
||||
{active.length === 0 ? (
|
||||
<div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}>
|
||||
No active violations on record.
|
||||
</div>
|
||||
) : (
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Date</th>
|
||||
<th style={s.th}>Violation</th>
|
||||
<th style={s.th}>Pts</th>
|
||||
<th style={s.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td style={s.td}>{v.incident_date}</td>
|
||||
<td style={s.td}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{v.violation_name}
|
||||
{v.amendment_count > 0 && (
|
||||
<span style={s.amendBadge}>{v.amendment_count} edit{v.amendment_count !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
||||
{v.details && (
|
||||
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>{v.details}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...s.td, fontWeight: 700 }}>{v.points}</td>
|
||||
<td style={s.td}>
|
||||
<button style={s.amendBtn} onClick={(e) => { e.stopPropagation(); setAmending(v); }}>
|
||||
Amend
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#ffc107')}
|
||||
onClick={(e) => { e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
|
||||
>
|
||||
Negate
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#ff4d4f')}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
|
||||
>
|
||||
{confirmDel === v.id ? 'Cancel' : 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
style={s.pdfBtn}
|
||||
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
{confirmDel === v.id && (
|
||||
<div style={s.deleteConfirm}>
|
||||
Permanently delete? This cannot be undone.
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
style={s.actionBtn('#ff4d4f')}
|
||||
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#888')}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* ── Negated / Resolved Violations ── */}
|
||||
{negated.length > 0 && (
|
||||
<>
|
||||
<div style={s.sectionHd}>Negated / Resolved</div>
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Date</th>
|
||||
<th style={s.th}>Violation</th>
|
||||
<th style={s.th}>Pts</th>
|
||||
<th style={s.th}>Resolution</th>
|
||||
<th style={s.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{negated.map((v) => (
|
||||
<tr key={v.id} style={s.negatedRow}>
|
||||
<td style={s.td}>{v.incident_date}</td>
|
||||
<td style={s.td}>
|
||||
<div style={{ fontWeight: 600 }}>{v.violation_name}</div>
|
||||
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
||||
</td>
|
||||
<td style={s.td}>{v.points}</td>
|
||||
<td style={s.td}>
|
||||
<span style={s.resTag}>{v.resolution_type}</span>
|
||||
{v.resolution_details && (
|
||||
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>
|
||||
{v.resolution_details}
|
||||
</div>
|
||||
)}
|
||||
{v.resolved_by && (
|
||||
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>by {v.resolved_by}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={s.td}>
|
||||
<button
|
||||
style={s.actionBtn('#4db6ac')}
|
||||
onClick={(e) => { e.stopPropagation(); handleRestore(v.id); }}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#ff4d4f')}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
|
||||
>
|
||||
{confirmDel === v.id ? 'Cancel' : 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
style={s.pdfBtn}
|
||||
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
{confirmDel === v.id && (
|
||||
<div style={s.deleteConfirm}>
|
||||
Permanently delete? This cannot be undone.
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
style={s.actionBtn('#ff4d4f')}
|
||||
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#888')}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals rendered outside panel to avoid z-index nesting issues */}
|
||||
{negating && (
|
||||
<NegateModal
|
||||
violation={negating}
|
||||
onConfirm={handleNegate}
|
||||
onCancel={() => setNegating(null)}
|
||||
/>
|
||||
)}
|
||||
{editingEmp && employee && (
|
||||
<EditEmployeeModal
|
||||
employee={employee}
|
||||
onClose={() => setEditingEmp(false)}
|
||||
onSaved={() => { toast.success('Employee updated.'); load(); }}
|
||||
/>
|
||||
)}
|
||||
{amending && (
|
||||
<AmendViolationModal
|
||||
violation={amending}
|
||||
onClose={() => setAmending(null)}
|
||||
onSaved={() => { toast.success('Violation amended.'); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const s = {
|
||||
wrapper: { marginTop: '20px' },
|
||||
sectionHd: {
|
||||
fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px', marginBottom: '8px',
|
||||
},
|
||||
display: {
|
||||
background: '#181924', border: '1px solid #2a2b3a', borderRadius: '6px',
|
||||
padding: '10px 12px', fontSize: '13px', color: '#f8f9fa', minHeight: '36px',
|
||||
cursor: 'pointer', position: 'relative',
|
||||
},
|
||||
displayEmpty: {
|
||||
color: '#555770', fontStyle: 'italic',
|
||||
},
|
||||
editHint: {
|
||||
position: 'absolute', right: '8px', top: '8px',
|
||||
fontSize: '10px', color: '#555770',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%', background: '#0d1117', border: '1px solid #4d6fa8',
|
||||
borderRadius: '6px', color: '#f8f9fa', fontSize: '13px',
|
||||
padding: '10px 12px', resize: 'vertical', minHeight: '80px',
|
||||
boxSizing: 'border-box', fontFamily: 'inherit', outline: 'none',
|
||||
},
|
||||
actions: { display: 'flex', gap: '8px', marginTop: '8px' },
|
||||
saveBtn: {
|
||||
background: '#1a3a6b', border: '1px solid #4d6fa8', color: '#90caf9',
|
||||
borderRadius: '5px', padding: '5px 14px', fontSize: '12px',
|
||||
cursor: 'pointer', fontWeight: 600,
|
||||
},
|
||||
cancelBtn: {
|
||||
background: 'none', border: '1px solid #444', color: '#888',
|
||||
borderRadius: '5px', padding: '5px 14px', fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
saving: { fontSize: '12px', color: '#9ca0b8', alignSelf: 'center' },
|
||||
tagRow: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' },
|
||||
tag: {
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
|
||||
fontSize: '11px', fontWeight: 600, background: '#1a2a3a',
|
||||
color: '#90caf9', border: '1px solid #2a3a5a', cursor: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
// Quick-add tags for common HR flags
|
||||
const QUICK_TAGS = ['On PIP', 'Union member', 'Probationary', 'Pending investigation', 'FMLA', 'ADA'];
|
||||
|
||||
export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(initialNotes || '');
|
||||
const [saved, setSaved] = useState(initialNotes || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.patch(`/api/employees/${employeeId}/notes`, { notes: draft });
|
||||
setSaved(draft);
|
||||
setEditing(false);
|
||||
if (onSaved) onSaved(draft);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraft(saved);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const addTag = (tag) => {
|
||||
const current = draft.trim();
|
||||
// Don't add a tag that's already present
|
||||
if (current.includes(tag)) return;
|
||||
setDraft(current ? `${current}\n${tag}` : tag);
|
||||
};
|
||||
|
||||
// Parse saved notes into display lines
|
||||
const lines = saved ? saved.split('\n').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div style={s.wrapper}>
|
||||
<div style={s.sectionHd}>Notes & Flags</div>
|
||||
|
||||
{!editing ? (
|
||||
<div
|
||||
style={s.display}
|
||||
onClick={() => { setDraft(saved); setEditing(true); }}
|
||||
title="Click to edit"
|
||||
>
|
||||
<span style={s.editHint}>✎ edit</span>
|
||||
{lines.length === 0 ? (
|
||||
<span style={s.displayEmpty}>No notes — click to add</span>
|
||||
) : (
|
||||
<div style={s.tagRow}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} style={s.tag}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Quick-add tag buttons */}
|
||||
<div style={{ ...s.tagRow, marginBottom: '6px' }}>
|
||||
{QUICK_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
style={{
|
||||
...s.tag,
|
||||
cursor: 'pointer',
|
||||
background: draft.includes(tag) ? '#0e2a3a' : '#1a2a3a',
|
||||
opacity: draft.includes(tag) ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => addTag(tag)}
|
||||
title="Add tag"
|
||||
>
|
||||
+ {tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
style={s.textarea}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder="Free-text notes — one per line or comma-separated. Does not affect CPAS scoring."
|
||||
autoFocus
|
||||
/>
|
||||
<div style={s.actions}>
|
||||
<button style={s.saveBtn} onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Notes'}
|
||||
</button>
|
||||
<button style={s.cancelBtn} onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
{saving && <span style={s.saving}>Saving…</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
modal: {
|
||||
width: '480px', maxWidth: '95vw', background: '#111217', borderRadius: '12px',
|
||||
boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
|
||||
overflow: 'hidden', border: '1px solid #2a2b3a',
|
||||
},
|
||||
header: {
|
||||
padding: '18px 24px', borderBottom: '1px solid #222',
|
||||
background: 'linear-gradient(135deg, #000000, #151622)',
|
||||
},
|
||||
title: { fontSize: '18px', fontWeight: 700 },
|
||||
subtitle: { fontSize: '12px', color: '#c0c2d6', marginTop: '4px' },
|
||||
body: { padding: '18px 24px 8px 24px' },
|
||||
pill: {
|
||||
background: '#3b2e00', borderRadius: '6px', padding: '8px 10px',
|
||||
fontSize: '12px', color: '#ffd666', border: '1px solid #d4af37', marginBottom: '14px',
|
||||
},
|
||||
label: { fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
|
||||
input: {
|
||||
width: '100%', padding: '9px 10px', borderRadius: '6px',
|
||||
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
|
||||
fontSize: '13px', fontFamily: 'inherit', marginBottom: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%', minHeight: '80px', resize: 'vertical',
|
||||
padding: '9px 10px', borderRadius: '6px', border: '1px solid #333544',
|
||||
background: '#050608', color: '#f8f9fa', fontSize: '13px',
|
||||
fontFamily: 'inherit', marginBottom: '14px', boxSizing: 'border-box',
|
||||
},
|
||||
footer: {
|
||||
display: 'flex', justifyContent: 'flex-end', gap: '10px',
|
||||
padding: '16px 24px 20px 24px', background: '#0c0d14', borderTop: '1px solid #222',
|
||||
},
|
||||
btnCancel: {
|
||||
padding: '10px 20px', borderRadius: '6px', border: '1px solid #333544',
|
||||
background: '#050608', color: '#f8f9fa', fontWeight: 600,
|
||||
fontSize: '13px', cursor: 'pointer',
|
||||
},
|
||||
btnConfirm: {
|
||||
padding: '10px 22px', borderRadius: '6px', border: 'none',
|
||||
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
|
||||
color: '#000', fontWeight: 700, fontSize: '13px',
|
||||
cursor: 'pointer', textTransform: 'uppercase',
|
||||
},
|
||||
};
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
'Corrective Training Completed',
|
||||
'Verbal Warning Issued',
|
||||
'Written Warning Issued',
|
||||
'Management Review',
|
||||
'Policy Exception Approved',
|
||||
'Data Entry Error',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||
const [resolutionType, setResolutionType] = useState('Corrective Training Completed');
|
||||
const [details, setDetails] = useState('');
|
||||
const [resolvedBy, setResolvedBy] = useState('');
|
||||
|
||||
if (!violation) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!onConfirm) return;
|
||||
onConfirm({
|
||||
resolution_type: resolutionType,
|
||||
details,
|
||||
resolved_by: resolvedBy,
|
||||
});
|
||||
};
|
||||
|
||||
// FIX: overlay click only closes on backdrop, NOT modal children
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget && onCancel) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||
{/* FIX: stopPropagation prevents modal clicks from bubbling to overlay */}
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
<div style={s.header}>
|
||||
<div style={s.title}>Negate Violation</div>
|
||||
<div style={s.subtitle}>
|
||||
Record resolution for: <strong>{violation.violation_name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
<div style={s.pill}>
|
||||
⚠ {violation.points} pt{violation.points !== 1 ? 's' : ''} · {violation.incident_date} · {violation.category}
|
||||
</div>
|
||||
|
||||
<div style={s.label}>Resolution Type</div>
|
||||
<select
|
||||
style={s.input}
|
||||
value={resolutionType}
|
||||
onChange={(e) => setResolutionType(e.target.value)}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={s.label}>Details / Notes</div>
|
||||
<textarea
|
||||
style={s.textarea}
|
||||
placeholder="Describe the resolution or context…"
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
/>
|
||||
|
||||
<div style={s.label}>Resolved By</div>
|
||||
<input
|
||||
style={s.input}
|
||||
placeholder="Manager or HR name…"
|
||||
value={resolvedBy}
|
||||
onChange={(e) => setResolvedBy(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={s.footer}>
|
||||
<button style={s.btnCancel} onClick={onCancel}>Cancel</button>
|
||||
<button style={s.btnConfirm} onClick={handleConfirm}>Confirm Negation</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
// Minimal Markdown to HTML renderer (headings, bold, inline-code, tables, hr, ul, ol, paragraphs)
|
||||
function mdToHtml(md) {
|
||||
const lines = md.split('\n');
|
||||
const out = [];
|
||||
let i = 0, inUl = false, inOl = false, inTable = false;
|
||||
|
||||
const close = () => {
|
||||
if (inUl) { out.push('</ul>'); inUl = false; }
|
||||
if (inOl) { out.push('</ol>'); inOl = false; }
|
||||
if (inTable) { out.push('</tbody></table>'); inTable = false; }
|
||||
};
|
||||
const inline = s =>
|
||||
s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g,'<code>$1</code>');
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('```')) { close(); i++; while (i < lines.length && !lines[i].startsWith('```')) i++; i++; continue; }
|
||||
if (/^---+$/.test(line.trim())) { close(); out.push('<hr>'); i++; continue; }
|
||||
const hm = line.match(/^(#{1,4})\s+(.+)/);
|
||||
if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; }
|
||||
if (line.trim().startsWith('|')) {
|
||||
const cells = line.trim().replace(/^\|||\|$/g,'').split('|').map(c=>c.trim());
|
||||
if (!inTable) { close(); inTable=true; out.push('<table><thead><tr>'); cells.forEach(c=>out.push(`<th>${inline(c)}</th>`)); out.push('</tr></thead><tbody>'); i++; if (i < lines.length && /^[\|\s\:\-]+$/.test(lines[i])) i++; continue; }
|
||||
else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); i++; continue; }
|
||||
}
|
||||
const ul = line.match(/^[-*]\s+(.*)/);
|
||||
if (ul) { if (inTable) close(); if (!inUl) { if (inOl){out.push('</ol>');inOl=false;} out.push('<ul>');inUl=true; } out.push(`<li>${inline(ul[1])}</li>`); i++; continue; }
|
||||
const ol = line.match(/^\d+\.\s+(.*)/);
|
||||
if (ol) { if (inTable) close(); if (!inOl) { if (inUl){out.push('</ul>');inUl=false;} out.push('<ol>');inOl=true; } out.push(`<li>${inline(ol[1])}</li>`); i++; continue; }
|
||||
if (line.trim() === '') { close(); i++; continue; }
|
||||
close(); out.push(`<p>${inline(line)}</p>`); i++;
|
||||
}
|
||||
close();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function buildToc(md) {
|
||||
return md.split('\n').reduce((acc, line) => {
|
||||
const m = line.match(/^(#{1,2})\s+(.+)/);
|
||||
if (m) acc.push({ level: m[1].length, text: m[2], id: m[2].toLowerCase().replace(/[^a-z0-9]+/g,'-') });
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
// ——— Styles ——————————————————————————————————————————————————————————————————
|
||||
const S = {
|
||||
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' },
|
||||
panel: { background:'#111217', color:'#f8f9fa', width:'760px', maxWidth:'95vw', height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', display:'flex', flexDirection:'column' },
|
||||
header: { background:'linear-gradient(135deg,#000000,#151622)', color:'white', padding:'22px 28px', position:'sticky', top:0, zIndex:10, borderBottom:'1px solid #222', display:'flex', alignItems:'center', justifyContent:'space-between' },
|
||||
closeBtn:{ background:'none', border:'none', color:'white', fontSize:'22px', cursor:'pointer', lineHeight:1 },
|
||||
toc: { background:'#0d1117', borderBottom:'1px solid #1e1f2e', padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px' },
|
||||
body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' },
|
||||
footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' },
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
.adm h1 { font-size:21px; font-weight:800; color:#f8f9fa; margin:28px 0 10px; border-bottom:1px solid #2a2b3a; padding-bottom:8px }
|
||||
.adm h2 { font-size:16px; font-weight:700; color:#d4af37; margin:28px 0 6px; letter-spacing:.2px }
|
||||
.adm h3 { font-size:12px; font-weight:700; color:#90caf9; margin:18px 0 4px; text-transform:uppercase; letter-spacing:.5px }
|
||||
.adm h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px }
|
||||
.adm p { color:#c8ccd8; margin:5px 0 10px }
|
||||
.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0 }
|
||||
.adm strong { color:#f8f9fa }
|
||||
.adm code { background:#0d1117; color:#79c0ff; border:1px solid #2a2b3a; border-radius:4px; padding:1px 6px; font-family:'Consolas','Fira Code',monospace; font-size:12px }
|
||||
.adm ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
|
||||
.adm ol { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
|
||||
.adm li { margin:4px 0 }
|
||||
.adm table { width:100%; border-collapse:collapse; font-size:12px; background:#181924; border-radius:6px; overflow:hidden; border:1px solid #2a2b3a; margin:10px 0 16px }
|
||||
.adm th { background:#050608; padding:8px 12px; text-align:left; color:#f8f9fa; font-weight:600; font-size:11px; text-transform:uppercase; border-bottom:1px solid #2a2b3a }
|
||||
.adm td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8 }
|
||||
.adm tr:last-child td { border-bottom:none }
|
||||
.adm tr:hover td { background:#1e1f2e }
|
||||
`;
|
||||
|
||||
// ——— Admin guide content (no install / Docker content) ————————————————————
|
||||
const GUIDE_MD = `# CPAS Tracker — Admin Guide
|
||||
|
||||
Internal tool for CPAS violation documentation, workforce standing management, and audit compliance. All data is stored locally in the Docker container volume — there is no external dependency.
|
||||
|
||||
---
|
||||
|
||||
## How Scoring Works
|
||||
|
||||
Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
|
||||
|
||||
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
|
||||
|
||||
## Tier Reference
|
||||
|
||||
| Points | Tier | Label |
|
||||
|--------|------|-------|
|
||||
| 0–4 | 0–1 | Elite Standing |
|
||||
| 5–9 | 1 | Realignment |
|
||||
| 10–14 | 2 | Administrative Lockdown |
|
||||
| 15–19 | 3 | Verification |
|
||||
| 20–24 | 4 | Risk Mitigation |
|
||||
| 25–29 | 5 | Final Decision |
|
||||
| 30+ | 6 | Separation |
|
||||
|
||||
The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
|
||||
|
||||
---
|
||||
|
||||
## Feature Map
|
||||
|
||||
### Dashboard
|
||||
|
||||
The main view. Employees are sorted by active CPAS points, highest first.
|
||||
|
||||
- **Stat cards** — live counts: total employees, zero-point (elite), with active points, at-risk, highest score
|
||||
- **Search / filter** — by name, department, or supervisor; narrows the table in real time
|
||||
- **At-risk badge** — gold flag on rows where the employee is within 2 pts of the next tier
|
||||
- **Audit Log button** — opens the filterable, paginated write-action log (top right of the dashboard toolbar)
|
||||
- **Click any name** — opens that employee's full profile modal
|
||||
|
||||
---
|
||||
|
||||
### Logging a Violation
|
||||
|
||||
Use the **+ New Violation** tab.
|
||||
|
||||
1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
|
||||
2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
|
||||
3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
|
||||
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
|
||||
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
|
||||
6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
|
||||
7. **Employee Acknowledgment** (optional): if the employee is present and acknowledges receipt, enter their printed name and the acknowledgment date. This replaces the blank signature line on the PDF with a recorded acknowledgment and an "Acknowledged" badge. Leave blank if the employee is not present or declines.
|
||||
8. Submit. A **PDF download link** appears immediately — download it for the employee's file.
|
||||
9. **Toast notifications** confirm success or surface errors at the top right of the screen. Toasts auto-dismiss after a few seconds.
|
||||
|
||||
---
|
||||
|
||||
### Employee Profile Modal
|
||||
|
||||
Click any name on the dashboard to open their profile.
|
||||
|
||||
#### Overview section
|
||||
Shows current tier badge, active points, and 90-day violation count.
|
||||
|
||||
#### Notes & Flags
|
||||
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
|
||||
|
||||
#### Point Expiration Timeline
|
||||
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
|
||||
|
||||
#### Violation History
|
||||
Full record of all submissions — active, negated, and resolved.
|
||||
|
||||
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
|
||||
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
|
||||
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
|
||||
- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
|
||||
|
||||
All actions trigger **toast notifications** confirming success or surfacing errors.
|
||||
|
||||
#### Edit Employee
|
||||
Update name, department, or supervisor. Changes are logged to the audit trail.
|
||||
|
||||
#### Merge Duplicate
|
||||
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
|
||||
|
||||
---
|
||||
|
||||
### Audit Log
|
||||
|
||||
Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
|
||||
|
||||
- Filter by entity type: **employee** or **violation**
|
||||
- Filter by action: created, edited, merged, negated, restored, amended, deleted, notes updated
|
||||
- Paginated with load-more; most recent entries first
|
||||
|
||||
The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
|
||||
|
||||
---
|
||||
|
||||
### Violation Amendment
|
||||
|
||||
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
|
||||
|
||||
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
|
||||
|
||||
**Immutable fields:** violation type, incident date, point value.
|
||||
|
||||
Each amendment stores a before/after diff for every changed field. Amendment history is accessible from the violation card in the employee's history.
|
||||
|
||||
---
|
||||
|
||||
### Toast Notifications
|
||||
|
||||
All user actions across the application produce **toast notifications** — small slide-in messages at the top right of the screen.
|
||||
|
||||
- **Success** (green) — violation submitted, PDF downloaded, employee updated, etc.
|
||||
- **Error** (red) — API failures, validation errors, PDF generation issues
|
||||
- **Warning** (gold) — missing required fields, policy alerts
|
||||
- **Info** (blue) — general informational messages
|
||||
|
||||
Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has a progress bar countdown and a manual dismiss button. Up to 5 toasts can stack simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Immutability Rules — Quick Reference
|
||||
|
||||
| Action | Allowed? | Notes |
|
||||
|--------|----------|-------|
|
||||
| Edit violation type | No | Immutable after submission |
|
||||
| Edit incident date | No | Immutable after submission |
|
||||
| Edit point value | No | Immutable after submission |
|
||||
| Edit location / details / witness | Yes | Via Amend |
|
||||
| Edit acknowledged-by / acknowledged-date | Yes | Via Amend |
|
||||
| Negate (void) a violation | Yes | Soft delete; reversible |
|
||||
| Hard delete a violation | Yes | Permanent; use sparingly |
|
||||
| Edit employee name / dept / supervisor | Yes | Logged to audit trail |
|
||||
| Merge duplicate employees | Yes | Irreversible |
|
||||
| Add / edit employee notes | Yes | Does not affect score |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Shipped
|
||||
|
||||
- Container scaffold, violation form, employee intelligence
|
||||
- Recidivist auto-escalation, tier crossing warning
|
||||
- PDF generation with prior-points snapshot
|
||||
- Company dashboard, stat cards, at-risk badges
|
||||
- Employee profile modal — full history, negate/restore, hard delete
|
||||
- Employee edit and duplicate merge
|
||||
- Violation amendment with field-level diff log
|
||||
- Audit log — filterable, paginated, append-only
|
||||
- Employee notes and flags with quick-add HR tags
|
||||
- Point expiration timeline with tier-drop projections
|
||||
- In-app admin guide (this panel)
|
||||
- Acknowledgment signature field — employee name + date on form and PDF
|
||||
- Toast notification system — global feedback for all user actions
|
||||
|
||||
---
|
||||
|
||||
### Near-term
|
||||
|
||||
These are well-scoped additions that fit the current architecture without major changes.
|
||||
|
||||
- **CSV export** — one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
|
||||
- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
|
||||
|
||||
---
|
||||
|
||||
### Planned
|
||||
|
||||
Larger features that require more design work or infrastructure.
|
||||
|
||||
- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
|
||||
- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
|
||||
- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
|
||||
- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
|
||||
|
||||
---
|
||||
|
||||
### Future Considerations
|
||||
|
||||
These require meaningful infrastructure additions and should be evaluated against actual operational need before committing.
|
||||
|
||||
- **Multi-user auth** — role-based login (admin, supervisor, read-only). Currently the app assumes a trusted internal network with no authentication layer.
|
||||
- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+, automatically routed to their supervisor.
|
||||
- **Scheduled digest** — weekly email summary to supervisors showing their employees' current standings and any approaching thresholds.
|
||||
- **Automated DB backup** — scheduled snapshot of the database to a mounted backup volume or remote destination.
|
||||
- **Bulk CSV import** — migrate historical violation records from paper logs or a prior system.
|
||||
- **Dark/light theme toggle** — UI is currently dark-only.
|
||||
`;
|
||||
|
||||
// ——— Component ——————————————————————————————————————————————————————————————
|
||||
export default function ReadmeModal({ onClose }) {
|
||||
const bodyRef = useRef(null);
|
||||
const html = mdToHtml(GUIDE_MD);
|
||||
const toc = buildToc(GUIDE_MD);
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const scrollTo = id => {
|
||||
const el = bodyRef.current?.querySelector(`#${id}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={S.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<style>{CSS}</style>
|
||||
|
||||
<div style={S.panel} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={S.header}>
|
||||
<div>
|
||||
<div style={{ fontSize:'17px', fontWeight:800, letterSpacing:'.3px' }}>
|
||||
📋 CPAS Tracker — Admin Guide
|
||||
</div>
|
||||
<div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}>
|
||||
Feature map · workflows · roadmap · Esc or click outside to close
|
||||
</div>
|
||||
</div>
|
||||
<button style={S.closeBtn} onClick={onClose} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
{/* TOC strip */}
|
||||
<div style={S.toc}>
|
||||
{toc.map(h => (
|
||||
<button key={h.id} onClick={() => scrollTo(h.id)} style={{
|
||||
background:'none', border:'none', cursor:'pointer', padding:'3px 0',
|
||||
color: h.level === 1 ? '#f8f9fa' : '#d4af37',
|
||||
fontWeight: h.level === 1 ? 700 : 500,
|
||||
fontSize:'11px',
|
||||
}}>
|
||||
{h.level === 2 ? '↳ ' : ''}{h.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
style={S.body}
|
||||
className="adm"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={S.footer}>
|
||||
CPAS Violation Tracker · internal admin use only
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { getTier, getNextTier } from './CpasBadge';
|
||||
|
||||
/**
|
||||
* Shows a warning banner if adding `addingPoints` to `currentPoints`
|
||||
* would cross into a new CPAS tier.
|
||||
*/
|
||||
export default function TierWarning({ currentPoints, addingPoints }) {
|
||||
if (!currentPoints && currentPoints !== 0) return null;
|
||||
|
||||
const current = getTier(currentPoints);
|
||||
const projected = getTier(currentPoints + addingPoints);
|
||||
|
||||
if (current.label === projected.label) return null;
|
||||
|
||||
const tierUp = getNextTier(currentPoints);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#3b2e00',
|
||||
border: '2px solid #d4af37',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 16px',
|
||||
margin: '12px 0',
|
||||
fontSize: '13px',
|
||||
color: '#ffdf8a',
|
||||
}}>
|
||||
<strong style={{ color: '#ffd666' }}>⚠ Tier Escalation Warning</strong><br />
|
||||
Adding <strong>{addingPoints} point{addingPoints !== 1 ? 's' : ''}</strong> will move this employee
|
||||
from <strong>{current.label}</strong> to <strong>{projected.label}</strong>.
|
||||
{tierUp && (
|
||||
<span> Tier threshold crossed at <strong>{tierUp.min} points</strong>.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
success: { bg: '#053321', border: '#0f5132', color: '#9ef7c1', icon: '✓' },
|
||||
error: { bg: '#3c1114', border: '#f5c6cb', color: '#ffb3b8', icon: '✗' },
|
||||
info: { bg: '#0c1f3f', border: '#2563eb', color: '#93c5fd', icon: 'ℹ' },
|
||||
warning: { bg: '#3b2e00', border: '#d4af37', color: '#ffdf8a', icon: '⚠' },
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function Toast({ toast, onDismiss }) {
|
||||
const v = VARIANTS[toast.variant] || VARIANTS.info;
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 280);
|
||||
}, toast.duration || 4000);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [toast.id, toast.duration, onDismiss]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
setExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 280);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: v.bg,
|
||||
border: `1px solid ${v.border}`,
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
color: v.color,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
minWidth: '320px',
|
||||
maxWidth: '480px',
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
|
||||
animation: exiting ? 'toastOut 0.28s ease-in forwards' : 'toastIn 0.28s ease-out',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{ fontSize: '16px', lineHeight: 1, flexShrink: 0, marginTop: '1px' }}>{v.icon}</span>
|
||||
<span style={{ flex: 1, lineHeight: 1.5 }}>{toast.message}</span>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
style={{
|
||||
background: 'none', border: 'none', color: v.color, cursor: 'pointer',
|
||||
fontSize: '16px', padding: '0 0 0 8px', opacity: 0.7, lineHeight: 1, flexShrink: 0,
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, height: '3px',
|
||||
background: v.color, opacity: 0.4, borderRadius: '0 0 8px 8px',
|
||||
animation: `toastProgress ${toast.duration || 4000}ms linear forwards`,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((message, variant = 'info', duration = 4000) => {
|
||||
const id = ++nextId;
|
||||
setToasts(prev => {
|
||||
const next = [...prev, { id, message, variant, duration }];
|
||||
return next.length > 5 ? next.slice(-5) : next;
|
||||
});
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const toast = useCallback({
|
||||
success: (msg, dur) => addToast(msg, 'success', dur),
|
||||
error: (msg, dur) => addToast(msg, 'error', dur || 6000),
|
||||
info: (msg, dur) => addToast(msg, 'info', dur),
|
||||
warning: (msg, dur) => addToast(msg, 'warning', dur || 5000),
|
||||
}, [addToast]);
|
||||
|
||||
// Inject keyframes once
|
||||
useEffect(() => {
|
||||
if (document.getElementById('toast-keyframes')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toast-keyframes';
|
||||
style.textContent = `
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(100%); }
|
||||
}
|
||||
@keyframes toastProgress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
zIndex: 99999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} style={{ pointerEvents: 'auto' }}>
|
||||
<Toast toast={t} onDismiss={dismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { violationData, violationGroups } from '../data/violations';
|
||||
import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence';
|
||||
import CpasBadge from './CpasBadge';
|
||||
import TierWarning from './TierWarning';
|
||||
import ViolationHistory from './ViolationHistory';
|
||||
import ViolationTypeModal from './ViolationTypeModal';
|
||||
import { useToast } from './ToastProvider';
|
||||
import { DEPARTMENTS } from '../data/departments';
|
||||
|
||||
const s = {
|
||||
content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' },
|
||||
section: { background: '#181924', borderLeft: '4px solid #d4af37', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
|
||||
sectionTitle: { color: '#f8f9fa', fontSize: '20px', marginBottom: '15px', fontWeight: 700 },
|
||||
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
|
||||
item: { display: 'flex', flexDirection: 'column' },
|
||||
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px' },
|
||||
input: { padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa' },
|
||||
fullCol: { gridColumn: '1 / -1' },
|
||||
contextBox: { background: '#141623', border: '1px solid #333544', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#d1d3e0', marginTop: '4px' },
|
||||
repeatBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '11px', fontWeight: 700, background: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37' },
|
||||
repeatWarn: { background: '#3b2e00', border: '1px solid #d4af37', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#ffdf8a' },
|
||||
pointBox: { background: '#181200', border: '2px solid #d4af37', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
|
||||
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#ffd666', margin: '10px 0' },
|
||||
scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' },
|
||||
btnRow: { display: 'flex', gap: '15px', justifyContent: 'center', marginTop: '30px', flexWrap: 'wrap' },
|
||||
btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)', color: '#000', textTransform: 'uppercase' },
|
||||
btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' },
|
||||
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' },
|
||||
note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' },
|
||||
ackSection: { background: '#181924', borderLeft: '4px solid #2196F3', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
|
||||
ackHint: { fontSize: '12px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
|
||||
violationType: '', incidentDate: '', incidentTime: '',
|
||||
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
|
||||
acknowledgedBy: '', acknowledgedDate: '',
|
||||
};
|
||||
|
||||
export default function ViolationForm() {
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [violation, setViolation] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [lastViolId, setLastViolId] = useState(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [customTypes, setCustomTypes] = useState([]);
|
||||
const [typeModal, setTypeModal] = useState(null); // null | 'create' | <editing object>
|
||||
|
||||
const toast = useToast();
|
||||
const intel = useEmployeeIntelligence(form.employeeId || null);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
|
||||
fetchCustomTypes();
|
||||
}, []);
|
||||
|
||||
const fetchCustomTypes = () => {
|
||||
axios.get('/api/violation-types').then(r => setCustomTypes(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
// Build a map of custom types keyed by type_key for fast lookup
|
||||
const customTypeMap = useMemo(() =>
|
||||
Object.fromEntries(customTypes.map(t => [t.type_key, t])),
|
||||
[customTypes]
|
||||
);
|
||||
|
||||
// Merge hardcoded and custom violation groups for the dropdown
|
||||
const mergedGroups = useMemo(() => {
|
||||
const groups = {};
|
||||
// Start with all hardcoded groups
|
||||
Object.entries(violationGroups).forEach(([cat, items]) => {
|
||||
groups[cat] = [...items];
|
||||
});
|
||||
// Add custom types into their respective category, or create new group
|
||||
customTypes.forEach(t => {
|
||||
const item = {
|
||||
key: t.type_key,
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
minPoints: t.min_points,
|
||||
maxPoints: t.max_points,
|
||||
chapter: t.chapter || '',
|
||||
description: t.description || '',
|
||||
fields: t.fields,
|
||||
isCustom: true,
|
||||
customId: t.id,
|
||||
};
|
||||
if (!groups[t.category]) groups[t.category] = [];
|
||||
groups[t.category].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [customTypes]);
|
||||
|
||||
// Resolve a violation definition from either the hardcoded registry or custom types
|
||||
const resolveViolation = key => {
|
||||
if (violationData[key]) return violationData[key];
|
||||
const ct = customTypeMap[key];
|
||||
if (ct) return {
|
||||
name: ct.name,
|
||||
category: ct.category,
|
||||
chapter: ct.chapter || '',
|
||||
description: ct.description || '',
|
||||
minPoints: ct.min_points,
|
||||
maxPoints: ct.max_points,
|
||||
fields: ct.fields,
|
||||
isCustom: true,
|
||||
customId: ct.id,
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!violation || !form.violationType) return;
|
||||
const allTime = intel.countsAllTime[form.violationType];
|
||||
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
|
||||
setForm(prev => ({ ...prev, points: violation.maxPoints }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, points: violation.minPoints }));
|
||||
}
|
||||
}, [form.violationType, violation, intel.countsAllTime]);
|
||||
|
||||
const handleEmployeeSelect = e => {
|
||||
const emp = employees.find(x => x.id === parseInt(e.target.value));
|
||||
if (!emp) return;
|
||||
setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' }));
|
||||
};
|
||||
|
||||
const handleViolationChange = e => {
|
||||
const key = e.target.value;
|
||||
const v = resolveViolation(key);
|
||||
setViolation(v);
|
||||
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
|
||||
};
|
||||
|
||||
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!form.violationType) { toast.warning('Please select a violation type.'); return; }
|
||||
if (!form.employeeName) { toast.warning('Please enter an employee name.'); return; }
|
||||
try {
|
||||
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
|
||||
const employeeId = empRes.data.id;
|
||||
const violRes = await axios.post('/api/violations', {
|
||||
employee_id: employeeId,
|
||||
violation_type: form.violationType,
|
||||
violation_name: violation?.name || form.violationType,
|
||||
category: violation?.category || 'General',
|
||||
points: parseInt(form.points),
|
||||
incident_date: form.incidentDate,
|
||||
incident_time: form.incidentTime || null,
|
||||
location: form.location || null,
|
||||
details: form.additionalDetails || null,
|
||||
witness_name: form.witnessName || null,
|
||||
acknowledged_by: form.acknowledgedBy || null,
|
||||
acknowledged_date: form.acknowledgedDate || null,
|
||||
});
|
||||
|
||||
const newId = violRes.data.id;
|
||||
setLastViolId(newId);
|
||||
|
||||
const empList = await axios.get('/api/employees');
|
||||
setEmployees(empList.data);
|
||||
|
||||
toast.success(`Violation #${newId} recorded — click Download PDF to save the document.`);
|
||||
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
|
||||
setForm(EMPTY_FORM);
|
||||
setViolation(null);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.error || err.message;
|
||||
toast.error(`Failed to submit: ${msg}`);
|
||||
setStatus({ ok: false, msg: '✗ Error: ' + msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!lastViolId) return;
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/api/violations/${lastViolId}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `CPAS_Violation_${lastViolId}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('PDF downloaded successfully.');
|
||||
} catch (err) {
|
||||
toast.error('PDF generation failed: ' + err.message);
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showField = f => violation?.fields?.includes(f);
|
||||
const priorCount90 = key => intel.counts90[key] || 0;
|
||||
const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1;
|
||||
|
||||
return (
|
||||
<div style={s.content}>
|
||||
|
||||
<div style={s.section}>
|
||||
<h2 style={s.sectionTitle}>Employee Information</h2>
|
||||
|
||||
{intel.score && form.employeeId && (
|
||||
<div style={s.scoreRow}>
|
||||
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
|
||||
<CpasBadge points={intel.score.active_points} />
|
||||
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
|
||||
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employees.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={s.label}>Quick-Select Existing Employee:</label>
|
||||
<select style={s.input} onChange={handleEmployeeSelect} value={form.employeeId || ''}>
|
||||
<option value="">-- Select existing or enter new below --</option>
|
||||
{employees.map(e => (
|
||||
<option key={e.id} value={e.id}>{e.name}{e.department ? ` — ${e.department}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={s.grid}>
|
||||
{[['employeeName','Employee Name','John Doe'],['supervisor','Supervisor Name','Jane Smith'],['witnessName','Witness Name (Officer)','Officer Name']].map(([name,label,ph]) => (
|
||||
<div key={name} style={s.item}>
|
||||
<label style={s.label}>{label}:</label>
|
||||
<input style={s.input} type="text" name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
|
||||
</div>
|
||||
))}
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Department:</label>
|
||||
<select style={s.input} name="department" value={form.department} onChange={handleChange}>
|
||||
<option value="">-- Select Department --</option>
|
||||
{DEPARTMENTS.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={s.section}>
|
||||
<h2 style={s.sectionTitle}>Violation Details</h2>
|
||||
<div style={s.grid}>
|
||||
|
||||
<div style={{ ...s.item, ...s.fullCol }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
|
||||
<label style={{ ...s.label, marginBottom: 0 }}>Violation Type:</label>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{violation?.isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTypeModal(customTypeMap[form.violationType])}
|
||||
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #4caf50', background: '#1a2e1a', color: '#4caf50', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
Edit Type
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTypeModal('create')}
|
||||
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #d4af37', background: '#181200', color: '#ffd666', cursor: 'pointer', fontWeight: 600 }}
|
||||
title="Add a new custom violation type"
|
||||
>
|
||||
+ Add Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
|
||||
<option value="">-- Select Violation Type --</option>
|
||||
{Object.entries(mergedGroups).map(([group, items]) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{items.map(v => {
|
||||
const prior = priorCount90(v.key);
|
||||
return (
|
||||
<option key={v.key} value={v.key}>
|
||||
{v.name}{v.isCustom ? ' ✦' : ''}{prior > 0 ? ` ★ ${prior}x in 90 days` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{violation && (
|
||||
<div style={s.contextBox}>
|
||||
<strong>{violation.name}</strong>
|
||||
{violation.isCustom && (
|
||||
<span style={{ display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50' }}>
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
{isRepeat(form.violationType) && form.employeeId && (
|
||||
<span style={s.repeatBadge}>
|
||||
★ Repeat — {intel.countsAllTime[form.violationType]?.count}x prior
|
||||
</span>
|
||||
)}
|
||||
<br />{violation.description}<br />
|
||||
<span style={{ fontSize: '11px', color: '#a0a3ba' }}>{violation.chapter}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
|
||||
<div style={s.repeatWarn}>
|
||||
<strong>Repeat offense detected.</strong> Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Incident Date:</label>
|
||||
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
|
||||
</div>
|
||||
|
||||
{showField('time') && (
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Incident Time:</label>
|
||||
<input style={s.input} type="time" name="incidentTime" value={form.incidentTime} onChange={handleChange} />
|
||||
</div>
|
||||
)}
|
||||
{showField('minutes') && (
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Minutes Late:</label>
|
||||
<input style={s.input} type="number" name="minutesLate" value={form.minutesLate} onChange={handleChange} placeholder="15" />
|
||||
</div>
|
||||
)}
|
||||
{showField('amount') && (
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Amount / Value:</label>
|
||||
<input style={s.input} type="text" name="amount" value={form.amount} onChange={handleChange} placeholder="$150.00" />
|
||||
</div>
|
||||
)}
|
||||
{showField('location') && (
|
||||
<div style={{ ...s.item, ...s.fullCol }}>
|
||||
<label style={s.label}>Location / Context:</label>
|
||||
<input style={s.input} type="text" name="location" value={form.location} onChange={handleChange} placeholder="Office, vehicle, facility area, etc." />
|
||||
</div>
|
||||
)}
|
||||
{showField('description') && (
|
||||
<div style={{ ...s.item, ...s.fullCol }}>
|
||||
<label style={s.label}>Additional Details:</label>
|
||||
<textarea style={{ ...s.input, resize: 'vertical', minHeight: '80px' }} name="additionalDetails" value={form.additionalDetails} onChange={handleChange} placeholder="Provide specific context, observations, or details..." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{intel.score && violation && (
|
||||
<TierWarning
|
||||
currentPoints={intel.score.active_points}
|
||||
addingPoints={parseInt(form.points) || 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{violation && (
|
||||
<div style={s.pointBox}>
|
||||
<h4 style={{ color: '#ffdf8a', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
||||
<p style={{ margin: 0 }}>
|
||||
{violation.name}: {violation.minPoints === violation.maxPoints
|
||||
? `${violation.minPoints} Points (Fixed)`
|
||||
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
||||
</p>
|
||||
<input style={{ width: '100%', marginTop: '10px' }} type="range" name="points"
|
||||
min={violation.minPoints} max={violation.maxPoints}
|
||||
value={form.points} onChange={handleChange} />
|
||||
<div style={s.pointValue}>{form.points} Points</div>
|
||||
<p style={{ fontSize: '12px', color: '#d1d3e0' }}>Adjust to reflect severity and context</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Acknowledgment Signature Section */}
|
||||
<div style={s.ackSection}>
|
||||
<h2 style={{ ...s.sectionTitle, fontSize: '17px' }}>Employee Acknowledgment</h2>
|
||||
<p style={{ fontSize: '12px', color: '#9ca0b8', marginBottom: '14px', lineHeight: 1.6 }}>
|
||||
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
|
||||
This replaces the blank signature line on the PDF with a recorded acknowledgment.
|
||||
</p>
|
||||
<div style={s.grid}>
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Acknowledged By (Employee Name):</label>
|
||||
<input style={s.input} type="text" name="acknowledgedBy" value={form.acknowledgedBy} onChange={handleChange} placeholder="Employee's printed name" />
|
||||
<div style={s.ackHint}>Leave blank if employee is not present or declines to sign</div>
|
||||
</div>
|
||||
<div style={s.item}>
|
||||
<label style={s.label}>Acknowledgment Date:</label>
|
||||
<input style={s.input} type="date" name="acknowledgedDate" value={form.acknowledgedDate} onChange={handleChange} />
|
||||
<div style={s.ackHint}>Date the employee received and acknowledged this document</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.btnRow}>
|
||||
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
|
||||
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); setLastViolId(null); }}>
|
||||
Clear Form
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastViolId && status?.ok && (
|
||||
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...s.btnPdf, opacity: pdfLoading ? 0.7 : 1 }}
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
>
|
||||
{pdfLoading ? '⏳ Generating PDF...' : '⬇ Download PDF'}
|
||||
</button>
|
||||
<p style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '6px' }}>
|
||||
Violation #{lastViolId} — click to download the signed violation document
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && <div style={status.ok ? { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' } : { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }}>{status.msg}</div>}
|
||||
</form>
|
||||
|
||||
{form.employeeId && (
|
||||
<div style={s.section}>
|
||||
<h2 style={s.sectionTitle}>Violation History</h2>
|
||||
<ViolationHistory history={intel.history} loading={intel.loading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{typeModal && (
|
||||
<ViolationTypeModal
|
||||
editing={typeModal === 'create' ? null : typeModal}
|
||||
onClose={() => setTypeModal(null)}
|
||||
onSaved={saved => {
|
||||
fetchCustomTypes();
|
||||
setTypeModal(null);
|
||||
// Auto-select the newly created type; do nothing on delete (saved === null)
|
||||
if (saved) {
|
||||
const v = {
|
||||
name: saved.name,
|
||||
category: saved.category,
|
||||
chapter: saved.chapter || '',
|
||||
description: saved.description || '',
|
||||
minPoints: saved.min_points,
|
||||
maxPoints: saved.max_points,
|
||||
fields: saved.fields,
|
||||
isCustom: true,
|
||||
customId: saved.id,
|
||||
};
|
||||
setViolation(v);
|
||||
setForm(prev => ({ ...prev, violationType: saved.type_key, points: saved.min_points }));
|
||||
} else {
|
||||
// Type was deleted — clear selection if it was the active type
|
||||
setForm(prev => {
|
||||
const stillExists = violationData[prev.violationType] || false;
|
||||
return stillExists ? prev : { ...prev, violationType: '', points: 1 };
|
||||
});
|
||||
setViolation(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const s = {
|
||||
wrapper: { marginTop: '24px' },
|
||||
title: { color: '#b5b5c0', fontSize: '16px', fontWeight: 700, marginBottom: '10px' },
|
||||
table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px', background: '#111217', borderRadius: '6px', overflow: 'hidden', border: '1px solid #222' },
|
||||
th: { background: '#000000', color: '#f8f9fa', padding: '8px 10px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
|
||||
td: { padding: '8px 10px', borderBottom: '1px solid #1c1d29', color: '#f8f9fa', verticalAlign: 'middle' },
|
||||
trEven: { background: '#111217' },
|
||||
trOdd: { background: '#151622' },
|
||||
pts: { fontWeight: 700, color: '#667eea' },
|
||||
toggle: { background: 'none', border: 'none', color: '#667eea', cursor: 'pointer', fontSize: '13px', padding: 0, textDecoration: 'underline' },
|
||||
empty: { color: '#77798a', fontStyle: 'italic', fontSize: '13px', marginTop: '8px' },
|
||||
};
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '–';
|
||||
const dt = new Date(d + 'T12:00:00');
|
||||
return dt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Chicago' });
|
||||
}
|
||||
|
||||
export default function ViolationHistory({ history, loading }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = expanded ? history : history.slice(0, 5);
|
||||
|
||||
if (loading) return <p style={s.empty}>Loading history...</p>;
|
||||
if (!history.length) return <p style={s.empty}>No violations on record for this employee.</p>;
|
||||
|
||||
return (
|
||||
<div style={s.wrapper}>
|
||||
<div style={s.title}>Recent Violations ({history.length} total)</div>
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Date</th>
|
||||
<th style={s.th}>Violation</th>
|
||||
<th style={s.th}>Category</th>
|
||||
<th style={s.th}>Points</th>
|
||||
<th style={s.th}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((v, i) => (
|
||||
<tr key={v.id} style={i % 2 === 0 ? s.trEven : s.trOdd}>
|
||||
<td style={s.td}>{formatDate(v.incident_date)}</td>
|
||||
<td style={s.td}>{v.violation_name}</td>
|
||||
<td style={{ ...s.td, color: '#c0c2d6' }}>{v.category}</td>
|
||||
<td style={{ ...s.td, ...s.pts }}>{v.points}</td>
|
||||
<td style={{ ...s.td, color: '#c0c2d6' }}>{v.details || '–'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{history.length > 5 && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button style={s.toggle} onClick={() => setExpanded(e => !e)}>
|
||||
{expanded ? '▲ Show less' : `▼ Show all ${history.length} violations`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from './ToastProvider';
|
||||
|
||||
// Existing hardcoded categories — used for datalist autocomplete
|
||||
const KNOWN_CATEGORIES = [
|
||||
'Attendance & Punctuality',
|
||||
'Administrative Integrity',
|
||||
'Financial Stewardship',
|
||||
'Operational Response',
|
||||
'Professional Conduct',
|
||||
'Work From Home',
|
||||
'Safety & Security',
|
||||
];
|
||||
|
||||
const CONTEXT_FIELDS = [
|
||||
{ key: 'time', label: 'Incident Time' },
|
||||
{ key: 'minutes', label: 'Minutes Late' },
|
||||
{ key: 'amount', label: 'Amount / Value' },
|
||||
{ key: 'location', label: 'Location / Context' },
|
||||
{ key: 'description', label: 'Additional Details' },
|
||||
];
|
||||
|
||||
const s = {
|
||||
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' },
|
||||
modal: { background: '#111217', border: '1px solid #2a2b3a', borderRadius: '10px', width: '100%', maxWidth: '620px', maxHeight: '90vh', overflowY: 'auto', padding: '32px' },
|
||||
title: { color: '#f8f9fa', fontSize: '20px', fontWeight: 700, marginBottom: '24px', borderBottom: '1px solid #2a2b3a', paddingBottom: '12px' },
|
||||
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px', display: 'block' },
|
||||
input: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', boxSizing: 'border-box' },
|
||||
textarea: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '13px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', resize: 'vertical', minHeight: '80px', boxSizing: 'border-box' },
|
||||
group: { marginBottom: '18px' },
|
||||
hint: { fontSize: '11px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
|
||||
row: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' },
|
||||
toggle: { display: 'flex', gap: '8px', marginTop: '6px' },
|
||||
toggleBtn: (active) => ({
|
||||
padding: '7px 18px', borderRadius: '4px', fontSize: '13px', fontWeight: 600, cursor: 'pointer', border: '1px solid',
|
||||
background: active ? '#d4af37' : '#050608',
|
||||
color: active ? '#000' : '#9ca0b8',
|
||||
borderColor: active ? '#d4af37' : '#333544',
|
||||
}),
|
||||
fieldGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' },
|
||||
checkbox: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#d1d3e0', cursor: 'pointer' },
|
||||
btnRow: { display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '28px', paddingTop: '16px', borderTop: '1px solid #2a2b3a' },
|
||||
btnSave: { padding: '10px 28px', fontSize: '14px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)', color: '#000' },
|
||||
btnDanger: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #721c24', borderRadius: '6px', cursor: 'pointer', background: '#3c1114', color: '#ffb3b8' },
|
||||
btnCancel: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa' },
|
||||
section: { background: '#181924', border: '1px solid #2a2b3a', borderRadius: '6px', padding: '16px', marginBottom: '18px' },
|
||||
secTitle: { color: '#d4af37', fontSize: '13px', fontWeight: 700, marginBottom: '12px', textTransform: 'uppercase', letterSpacing: '0.05em' },
|
||||
customBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50', verticalAlign: 'middle' },
|
||||
};
|
||||
|
||||
const EMPTY = {
|
||||
name: '', category: '', chapter: '', description: '',
|
||||
pointType: 'fixed', // 'fixed' | 'sliding'
|
||||
fixedPoints: 1,
|
||||
minPoints: 1,
|
||||
maxPoints: 5,
|
||||
fields: ['description'],
|
||||
};
|
||||
|
||||
export default function ViolationTypeModal({ onClose, onSaved, editing = null }) {
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Populate form when editing an existing type
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
const isSliding = editing.min_points !== editing.max_points;
|
||||
setForm({
|
||||
name: editing.name,
|
||||
category: editing.category,
|
||||
chapter: editing.chapter || '',
|
||||
description: editing.description || '',
|
||||
pointType: isSliding ? 'sliding' : 'fixed',
|
||||
fixedPoints: isSliding ? editing.min_points : editing.min_points,
|
||||
minPoints: editing.min_points,
|
||||
maxPoints: editing.max_points,
|
||||
fields: editing.fields || ['description'],
|
||||
});
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const set = (key, val) => setForm(prev => ({ ...prev, [key]: val }));
|
||||
|
||||
const toggleField = key => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
fields: prev.fields.includes(key)
|
||||
? prev.fields.filter(f => f !== key)
|
||||
: [...prev.fields, key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.warning('Violation name is required.'); return; }
|
||||
if (!form.category.trim()) { toast.warning('Category is required.'); return; }
|
||||
|
||||
const minPts = form.pointType === 'fixed' ? parseInt(form.fixedPoints) || 1 : parseInt(form.minPoints) || 1;
|
||||
const maxPts = form.pointType === 'fixed' ? minPts : parseInt(form.maxPoints) || 1;
|
||||
|
||||
if (maxPts < minPts) { toast.warning('Max points must be >= min points.'); return; }
|
||||
if (form.fields.length === 0) { toast.warning('Select at least one context field.'); return; }
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
category: form.category.trim(),
|
||||
chapter: form.chapter.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
min_points: minPts,
|
||||
max_points: maxPts,
|
||||
fields: form.fields,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let saved;
|
||||
if (editing) {
|
||||
const res = await axios.put(`/api/violation-types/${editing.id}`, payload);
|
||||
saved = res.data;
|
||||
toast.success(`"${saved.name}" updated.`);
|
||||
} else {
|
||||
const res = await axios.post('/api/violation-types', payload);
|
||||
saved = res.data;
|
||||
toast.success(`"${saved.name}" added to violation types.`);
|
||||
}
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editing) return;
|
||||
if (!window.confirm(`Delete "${editing.name}"? This cannot be undone and will fail if any violations reference this type.`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await axios.delete(`/api/violation-types/${editing.id}`);
|
||||
toast.success(`"${editing.name}" deleted.`);
|
||||
onSaved(null); // null signals a deletion to the parent
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || err.message);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div style={s.modal}>
|
||||
<div style={s.title}>
|
||||
{editing ? 'Edit Violation Type' : 'Add Violation Type'}
|
||||
{editing && <span style={s.customBadge}>CUSTOM</span>}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div style={s.section}>
|
||||
<div style={s.secTitle}>Violation Definition</div>
|
||||
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Violation Name *</label>
|
||||
<input
|
||||
style={s.input}
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
placeholder="e.g. Unauthorized System Access"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Category *</label>
|
||||
<input
|
||||
style={s.input}
|
||||
type="text"
|
||||
list="vt-categories"
|
||||
value={form.category}
|
||||
onChange={e => set('category', e.target.value)}
|
||||
placeholder="Select existing or type new category"
|
||||
/>
|
||||
<datalist id="vt-categories">
|
||||
{KNOWN_CATEGORIES.map(c => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
<div style={s.hint}>Choose an existing category or type a new one to create a new group in the dropdown.</div>
|
||||
</div>
|
||||
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Handbook Reference / Chapter</label>
|
||||
<input
|
||||
style={s.input}
|
||||
type="text"
|
||||
value={form.chapter}
|
||||
onChange={e => set('chapter', e.target.value)}
|
||||
placeholder="e.g. Chapter 4, Section 6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Description / Reference Text</label>
|
||||
<textarea
|
||||
style={s.textarea}
|
||||
value={form.description}
|
||||
onChange={e => set('description', e.target.value)}
|
||||
placeholder="Paste the relevant handbook language or describe the infraction in plain terms..."
|
||||
/>
|
||||
<div style={s.hint}>Shown in the context box on the violation form and printed on the PDF.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Point Assignment */}
|
||||
<div style={s.section}>
|
||||
<div style={s.secTitle}>Point Assignment</div>
|
||||
|
||||
<label style={s.label}>Point Type</label>
|
||||
<div style={s.toggle}>
|
||||
<button type="button" style={s.toggleBtn(form.pointType === 'fixed')} onClick={() => set('pointType', 'fixed')}>Fixed</button>
|
||||
<button type="button" style={s.toggleBtn(form.pointType === 'sliding')} onClick={() => set('pointType', 'sliding')}>Sliding Range</button>
|
||||
</div>
|
||||
<div style={{ ...s.hint, marginTop: '6px' }}>
|
||||
Fixed = exact value every time. Sliding = supervisor adjusts within a min/max range.
|
||||
</div>
|
||||
|
||||
{form.pointType === 'fixed' ? (
|
||||
<div style={{ ...s.group, marginTop: '14px' }}>
|
||||
<label style={s.label}>Points (Fixed)</label>
|
||||
<input
|
||||
style={{ ...s.input, width: '120px' }}
|
||||
type="number" min="1" max="30"
|
||||
value={form.fixedPoints}
|
||||
onChange={e => set('fixedPoints', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ ...s.row, marginTop: '14px' }}>
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Min Points</label>
|
||||
<input
|
||||
style={s.input}
|
||||
type="number" min="1" max="30"
|
||||
value={form.minPoints}
|
||||
onChange={e => set('minPoints', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={s.group}>
|
||||
<label style={s.label}>Max Points</label>
|
||||
<input
|
||||
style={s.input}
|
||||
type="number" min="1" max="30"
|
||||
value={form.maxPoints}
|
||||
onChange={e => set('maxPoints', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Fields */}
|
||||
<div style={s.section}>
|
||||
<div style={s.secTitle}>Context Fields</div>
|
||||
<div style={s.hint}>Select which additional fields appear on the violation form for this type.</div>
|
||||
<div style={s.fieldGrid}>
|
||||
{CONTEXT_FIELDS.map(({ key, label }) => (
|
||||
<label key={key} style={s.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.fields.includes(key)}
|
||||
onChange={() => toggleField(key)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={s.btnRow}>
|
||||
{editing && (
|
||||
<button type="button" style={s.btnDanger} onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete Type'}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" style={s.btnCancel} onClick={onClose}>Cancel</button>
|
||||
<button type="button" style={s.btnSave} onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : editing ? 'Save Changes' : 'Add Violation Type'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DEPARTMENTS = [
|
||||
'Administrative',
|
||||
'Business Development',
|
||||
'Design and Content',
|
||||
'Executive',
|
||||
'Implementation and Support',
|
||||
'Operations',
|
||||
'Production',
|
||||
];
|
||||
@@ -0,0 +1,248 @@
|
||||
export const violationData = {
|
||||
tardy: {
|
||||
name: 'Tardy Core Hours', category: 'Attendance & Punctuality',
|
||||
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['time', 'minutes', 'description'],
|
||||
description: 'Arriving 7+ minutes after 9:00 AM or start of mandatory meeting without prior excuse'
|
||||
},
|
||||
unplanned_absence: {
|
||||
name: 'Unplanned Absence', category: 'Attendance & Punctuality',
|
||||
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Absence from Core Hours without 48-hour notification, excluding verified emergencies'
|
||||
},
|
||||
chronic_underscheduling: {
|
||||
name: 'Chronic Under-Scheduling', category: 'Attendance & Punctuality',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Consistently failing to meet 40-hour weekly baseline'
|
||||
},
|
||||
pto_exhausted: {
|
||||
name: 'Absence - PTO Exhausted', category: 'Attendance & Punctuality',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Any absence after PTO bank reaches zero'
|
||||
},
|
||||
shadow_absenteeism: {
|
||||
name: 'Shadow Absenteeism', category: 'Attendance & Punctuality',
|
||||
minPoints: 5, maxPoints: 20, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to record partial-day absences or habitual PTO system bypass (20 pts for recidivists)'
|
||||
},
|
||||
manual_punch_1st: {
|
||||
name: 'Manual Punch Correction (1st)', category: 'Administrative Integrity',
|
||||
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'First failure to punch in/out requiring manual audit'
|
||||
},
|
||||
manual_punch_2nd: {
|
||||
name: 'Manual Punch Correction (2nd)', category: 'Administrative Integrity',
|
||||
minPoints: 2, maxPoints: 2, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Second failure requiring written action plan'
|
||||
},
|
||||
manual_punch_3rd: {
|
||||
name: 'Manual Punch Correction (3rd / Tier 1)', category: 'Administrative Integrity',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Repeated timekeeping negligence triggering formal Tier 1 realignment'
|
||||
},
|
||||
geolocation_1st: {
|
||||
name: 'Geolocation Integrity (1st)', category: 'Administrative Integrity',
|
||||
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Recording blind punch with location services disabled'
|
||||
},
|
||||
geolocation_2nd: {
|
||||
name: 'Geolocation Integrity (2nd)', category: 'Administrative Integrity',
|
||||
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Subsequent attempt to bypass location safeguards'
|
||||
},
|
||||
point_of_work: {
|
||||
name: 'Point-of-Work Integrity', category: 'Administrative Integrity',
|
||||
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Clocking in before arriving at assigned post or for personal errands'
|
||||
},
|
||||
financial_chargeback: {
|
||||
name: 'Financial Stewardship / Chargeback', category: 'Financial Stewardship',
|
||||
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['amount', 'description'],
|
||||
description: 'Monthly assessment for unsubstantiated expenses requiring chargeback'
|
||||
},
|
||||
receipt_negligence: {
|
||||
name: 'Receipt Negligence', category: 'Financial Stewardship',
|
||||
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['amount', 'description'],
|
||||
description: 'Frequent failure to provide company card expense documentation'
|
||||
},
|
||||
failure_to_respond: {
|
||||
name: 'Failure to Respond', category: 'Operational Response',
|
||||
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to respond promptly to internal/external requests during Core Hours'
|
||||
},
|
||||
sunset_rule: {
|
||||
name: 'Sunset Rule Violation', category: 'Operational Response',
|
||||
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to provide response or status update with commitment date by end of business day'
|
||||
},
|
||||
double_ask: {
|
||||
name: 'Double Ask Friction', category: 'Operational Response',
|
||||
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Forcing client to ask twice for same information due to employee neglect'
|
||||
},
|
||||
missed_deadline_internal: {
|
||||
name: 'Missed Deadline - Internal', category: 'Operational Response',
|
||||
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to meet internal project milestones'
|
||||
},
|
||||
missed_deadline_client: {
|
||||
name: 'Missed Deadline - Client', category: 'Operational Response',
|
||||
minPoints: 7, maxPoints: 7, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to meet high-impact client-facing deadline'
|
||||
},
|
||||
commitment_breach: {
|
||||
name: 'Commitment Breach', category: 'Operational Response',
|
||||
minPoints: 4, maxPoints: 4, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failing to meet commitment date without proactive prior notification'
|
||||
},
|
||||
communication_gap: {
|
||||
name: 'Communication Gap (15-min window)', category: 'Operational Response',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to respond within 15-minute window due to mobile device distraction'
|
||||
},
|
||||
quality_recidivism: {
|
||||
name: 'Quality Recidivism', category: 'Operational Response',
|
||||
minPoints: 4, maxPoints: 4, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Repetition of technical/administrative error previously corrected'
|
||||
},
|
||||
technical_negligence: {
|
||||
name: 'Technical Negligence', category: 'Operational Response',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Performance error resulting in rework, data loss, or equipment damage'
|
||||
},
|
||||
appearance: {
|
||||
name: 'Professional Appearance Violation', category: 'Professional Conduct',
|
||||
minPoints: 1, maxPoints: 3, chapter: 'Chapter 2, Section 9',
|
||||
fields: ['time', 'location', 'description'],
|
||||
description: 'Failure to maintain dress code standards (shirts, pants, shoes required)'
|
||||
},
|
||||
active_consumption: {
|
||||
name: 'Active Consumption Media', category: 'Professional Conduct',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['time', 'description'],
|
||||
description: 'Interactive social media/gaming during Core Hours'
|
||||
},
|
||||
tobacco_debris: {
|
||||
name: 'Tobacco Facility Debris', category: 'Professional Conduct',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Failure to maintain clean smoking area or flicking debris on grounds'
|
||||
},
|
||||
passive_insubordination: {
|
||||
name: 'Passive Insubordination', category: 'Professional Conduct',
|
||||
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Ignoring reasonable requests, emails, or syncs without open dissent'
|
||||
},
|
||||
lockdown_violation: {
|
||||
name: 'Lockdown Violation', category: 'Professional Conduct',
|
||||
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Using non-work media while under Tier 2 Administrative Friction'
|
||||
},
|
||||
vehicle_stewardship: {
|
||||
name: 'Vehicle Stewardship', category: 'Professional Conduct',
|
||||
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Persistent tobacco-free transit violation (odor/debris in company vehicle)'
|
||||
},
|
||||
defiant_insubordination: {
|
||||
name: 'Defiant Insubordination', category: 'Professional Conduct',
|
||||
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Openly refusing legal, ethical, or professional directive from management'
|
||||
},
|
||||
benefit_documentation: {
|
||||
name: 'Benefit Documentation Failure', category: 'Professional Conduct',
|
||||
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to provide insurance records for Workers Comp'
|
||||
},
|
||||
professional_dishonesty: {
|
||||
name: 'Professional Dishonesty', category: 'Professional Conduct',
|
||||
minPoints: 20, maxPoints: 20, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Falsifying time records, expenses, or reasons for absence'
|
||||
},
|
||||
wfh_submittal: {
|
||||
name: 'WFH Submittal Failure', category: 'Work From Home',
|
||||
minPoints: 1, maxPoints: 5, chapter: 'Chapter 4, Section 4.1',
|
||||
fields: ['description'],
|
||||
description: 'Failure to provide work-product summary or misrepresenting hours worked'
|
||||
},
|
||||
safety_minor: {
|
||||
name: 'Safety Violation - Minor', category: 'Safety & Security',
|
||||
minPoints: 1, maxPoints: 10, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Minor to moderate safety standard violations without immediate injury'
|
||||
},
|
||||
policy_isp: {
|
||||
name: 'Policy Non-Alignment - ISP', category: 'Safety & Security',
|
||||
minPoints: 5, maxPoints: 20, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Failure to adhere to Information Security Policy protocols'
|
||||
},
|
||||
workspace_safety: {
|
||||
name: 'Workspace Safety Neglect', category: 'Safety & Security',
|
||||
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Failure to maintain clean workspace or minor safety negligence'
|
||||
},
|
||||
distracted_driving: {
|
||||
name: 'Distracted Driving', category: 'Safety & Security',
|
||||
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Use of handheld mobile devices while operating vehicle for company business'
|
||||
},
|
||||
operational_sabotage: {
|
||||
name: 'Operational Sabotage', category: 'Safety & Security',
|
||||
minPoints: 20, maxPoints: 20, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Willful disregard for security/safety protocols resulting in breach or injury'
|
||||
},
|
||||
impairment_redzone: {
|
||||
name: 'Impairment in Red Zone', category: 'Safety & Security',
|
||||
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Operating machinery or working in Fabrication Area while under influence'
|
||||
},
|
||||
child_redzone: {
|
||||
name: 'Child in Red Zone', category: 'Safety & Security',
|
||||
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['location', 'description'],
|
||||
description: 'Bringing minor into active Fabrication Area (Suite 24/25)'
|
||||
},
|
||||
i9_falsification: {
|
||||
name: 'I-9 Eligibility Falsification', category: 'Safety & Security',
|
||||
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
|
||||
fields: ['description'],
|
||||
description: 'Falsifying work authorization or identity documentation'
|
||||
}
|
||||
};
|
||||
|
||||
export const violationGroups = Object.entries(violationData).reduce((acc, [key, val]) => {
|
||||
if (!acc[val.category]) acc[val.category] = [];
|
||||
acc[val.category].push({ key, ...val });
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Fetches CPAS score, 90-day violation type counts, and full history
|
||||
* for a given employeeId. Re-fetches whenever employeeId changes.
|
||||
*/
|
||||
export default function useEmployeeIntelligence(employeeId) {
|
||||
const [score, setScore] = useState(null);
|
||||
const [counts90, setCounts90] = useState({}); // { violation_type: count } 90-day
|
||||
const [countsAllTime, setCountsAllTime] = useState({}); // { violation_type: { count, max_points_used } }
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!employeeId) {
|
||||
setScore(null);
|
||||
setCounts90({});
|
||||
setCountsAllTime({});
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
axios.get(`/api/employees/${employeeId}/score`),
|
||||
axios.get(`/api/employees/${employeeId}/violation-counts`),
|
||||
axios.get(`/api/employees/${employeeId}/violation-counts/alltime`),
|
||||
axios.get(`/api/violations/employee/${employeeId}?limit=20`),
|
||||
]).then(([scoreRes, counts90Res, allTimeRes, historyRes]) => {
|
||||
setScore(scoreRes.data);
|
||||
setCounts90(counts90Res.data);
|
||||
setCountsAllTime(allTimeRes.data);
|
||||
setHistory(historyRes.data);
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [employeeId]);
|
||||
|
||||
return { score, counts90, countsAllTime, history, loading };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Mobile-Responsive Utilities for CPAS Tracker */
|
||||
/* Target: Standard phones 375px+ with graceful degradation */
|
||||
|
||||
/* Base responsive utilities */
|
||||
@media (max-width: 768px) {
|
||||
/* Hide scrollbars but keep functionality */
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets (min 44px) */
|
||||
button, a, input, select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Improve form input sizing on mobile */
|
||||
input, select, textarea {
|
||||
font-size: 16px !important; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and below */
|
||||
@media (max-width: 1024px) {
|
||||
.hide-tablet {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile portrait and landscape */
|
||||
@media (max-width: 768px) {
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.mobile-text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.mobile-no-padding {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-small-padding {
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
/* Stack flex containers vertically */
|
||||
.mobile-stack {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Allow horizontal scroll for tables */
|
||||
.mobile-scroll-x {
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Card-based layout helpers */
|
||||
.mobile-card {
|
||||
display: block !important;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
background: #181924;
|
||||
border: 1px solid #2a2b3a;
|
||||
}
|
||||
|
||||
.mobile-card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1c1d29;
|
||||
}
|
||||
|
||||
.mobile-card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-card-label {
|
||||
font-weight: 600;
|
||||
color: #9ca0b8;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mobile-card-value {
|
||||
font-weight: 600;
|
||||
color: #f8f9fa;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile phones */
|
||||
@media (max-width: 480px) {
|
||||
.hide-small-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility for sticky positioning on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #000000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user