Fix math logic for timeline

This commit is contained in:
2026-05-19 00:33:08 -05:00
parent ba2b631e23
commit e2c352d518
44 changed files with 7660 additions and 22 deletions
@@ -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}>&copy; {year} Jason Stedwell</span>
<span style={sf.sep}>&middot;</span>
<DevTicker />
<span style={sf.sep}>&middot;</span>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer" style={sf.link}>
<GiteaIcon /> cpas
</a>
{sha && sha !== 'dev' && (
<>
<span style={sf.sep}>&middot;</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: 04 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 (04 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: 04 pts */}
<div
style={cardStyle(FILTER_ELITE, { borderTop: '3px solid #28a745' })}
className="dashboard-stat-card"
onClick={() => handleBadgeClick(FILTER_ELITE)}
title="Click to filter: Elite Standing (04 pts)"
>
<div style={{ ...s.statNum, color: '#6ee7b7' }} className="stat-num">{eliteCount}</div>
<div style={s.statLbl} className="stat-lbl">Elite Standing (04 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 &amp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.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 |
|--------|------|-------|
| 04 | 01 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 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'
}
});