Compare commits
2 Commits
e8962f058c
...
2a42e335ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a42e335ca | |||
| 333cad41d7 |
@@ -1,56 +1,39 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ViolationForm from './components/ViolationForm';
|
import ViolationForm from './components/ViolationForm';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
const styles = {
|
const tabs = [
|
||||||
body: {
|
{ id: 'dashboard', label: '📊 Dashboard' },
|
||||||
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
|
{ id: 'violation', label: '+ New Violation' },
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
];
|
||||||
minHeight: '100vh',
|
|
||||||
padding: '20px',
|
const s = {
|
||||||
margin: 0,
|
app: { minHeight: '100vh', background: '#f5f6fa', fontFamily: "'Segoe UI', Arial, sans-serif" },
|
||||||
},
|
nav: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0 },
|
||||||
container: {
|
logo: { color: 'white', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px', marginRight: '32px', padding: '18px 0' },
|
||||||
maxWidth: '1200px',
|
tab: (active) => ({
|
||||||
margin: '0 auto',
|
padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)',
|
||||||
background: 'white',
|
borderBottom: active ? '3px solid #667eea' : '3px solid transparent',
|
||||||
borderRadius: '12px',
|
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
background: 'none', border: 'none', borderBottom: active ? '3px solid #667eea' : '3px solid transparent',
|
||||||
overflow: 'hidden',
|
}),
|
||||||
},
|
card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' },
|
||||||
header: {
|
|
||||||
background: 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '30px',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
statusBar: {
|
|
||||||
fontSize: '11px',
|
|
||||||
color: '#aaa',
|
|
||||||
marginTop: '6px',
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [apiStatus, setApiStatus] = useState('checking...');
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/health')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => setApiStatus('● API connected'))
|
|
||||||
.catch(() => setApiStatus('⚠ API unreachable'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.body}>
|
<div style={s.app}>
|
||||||
<div style={styles.container}>
|
<nav style={s.nav}>
|
||||||
<div style={styles.header}>
|
<div style={s.logo}>CPAS Tracker</div>
|
||||||
<h1 style={{ margin: 0, fontSize: '28px' }}>CPAS Violation Documentation System</h1>
|
{tabs.map(t => (
|
||||||
<p style={{ margin: '8px 0 0', fontSize: '14px', opacity: 0.9 }}>
|
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
|
||||||
Generate Individual Violation Records with Contextual Fields
|
{t.label}
|
||||||
</p>
|
</button>
|
||||||
<p style={styles.statusBar}>{apiStatus}</p>
|
))}
|
||||||
</div>
|
</nav>
|
||||||
<ViolationForm />
|
<div style={s.card}>
|
||||||
|
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
173
client/src/components/Dashboard.jsx
Executable file
173
client/src/components/Dashboard.jsx
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import CpasBadge, { getTier } from './CpasBadge';
|
||||||
|
import EmployeeModal from './EmployeeModal';
|
||||||
|
|
||||||
|
const AT_RISK_THRESHOLD = 2; // points within next tier boundary
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
wrap: { padding: '40px' },
|
||||||
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
|
||||||
|
title: { fontSize: '24px', fontWeight: 700, color: '#2c3e50' },
|
||||||
|
subtitle: { fontSize: '13px', color: '#888', marginTop: '3px' },
|
||||||
|
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
|
||||||
|
statCard: { flex: '1', minWidth: '140px', background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: '8px', padding: '16px', textAlign: 'center' },
|
||||||
|
statNum: { fontSize: '28px', fontWeight: 800, color: '#2c3e50' },
|
||||||
|
statLbl: { fontSize: '11px', color: '#888', marginTop: '4px' },
|
||||||
|
search: { padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', width: '260px' },
|
||||||
|
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' },
|
||||||
|
th: { background: '#34495e', color: 'white', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
|
||||||
|
td: { padding: '11px 14px', borderBottom: '1px solid #f0f0f0', fontSize: '13px', verticalAlign: 'middle' },
|
||||||
|
nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#667eea', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' },
|
||||||
|
atRiskBadge: { display: 'inline-block', marginLeft: '8px', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#fff3cd', color: '#856404', border: '1px solid #ffc107', verticalAlign: 'middle' },
|
||||||
|
zeroRow: { color: '#aaa', fontStyle: 'italic', fontSize: '12px' },
|
||||||
|
refreshBtn:{ padding: '9px 18px', background: '#667eea', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [employees, setEmployees] = useState([]);
|
||||||
|
const [filtered, setFiltered] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
axios.get('/api/dashboard')
|
||||||
|
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
setFiltered(employees.filter(e =>
|
||||||
|
e.name.toLowerCase().includes(q) ||
|
||||||
|
(e.department || '').toLowerCase().includes(q) ||
|
||||||
|
(e.supervisor || '').toLowerCase().includes(q)
|
||||||
|
));
|
||||||
|
}, [search, employees]);
|
||||||
|
|
||||||
|
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
||||||
|
const activeCount = employees.filter(e => e.active_points > 0).length;
|
||||||
|
const cleanCount = employees.filter(e => e.active_points === 0).length;
|
||||||
|
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.wrap}>
|
||||||
|
<div style={s.header}>
|
||||||
|
<div>
|
||||||
|
<div style={s.title}>Company Dashboard</div>
|
||||||
|
<div style={s.subtitle}>Click any employee name to view their full profile</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
|
<input style={s.search} placeholder="Search name, dept, supervisor…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
<button style={s.refreshBtn} onClick={load}>↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Stat cards ───────────────────────────────────────── */}
|
||||||
|
<div style={s.statsRow}>
|
||||||
|
<div style={s.statCard}>
|
||||||
|
<div style={s.statNum}>{employees.length}</div>
|
||||||
|
<div style={s.statLbl}>Total Employees</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#28a745' }}>{cleanCount}</div>
|
||||||
|
<div style={s.statLbl}>Elite Standing (0 pts)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #856404' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#856404' }}>{activeCount}</div>
|
||||||
|
<div style={s.statLbl}>With Active Points</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #ffc107' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#856404' }}>{atRiskCount}</div>
|
||||||
|
<div style={s.statLbl}>At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
|
||||||
|
<div style={{ ...s.statNum, color: '#c0392b' }}>{maxPoints}</div>
|
||||||
|
<div style={s.statLbl}>Highest Active Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Scoreboard table ─────────────────────────────────── */}
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#aaa', textAlign: 'center', padding: '40px' }}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table style={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={s.th}>#</th>
|
||||||
|
<th style={s.th}>Employee</th>
|
||||||
|
<th style={s.th}>Department</th>
|
||||||
|
<th style={s.th}>Supervisor</th>
|
||||||
|
<th style={s.th}>Tier / Standing</th>
|
||||||
|
<th style={s.th}>Active Points</th>
|
||||||
|
<th style={s.th}>90-Day Violations</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr><td colSpan={7} style={{ ...s.td, textAlign: 'center', ...s.zeroRow }}>No employees found.</td></tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((emp, i) => {
|
||||||
|
const risk = isAtRisk(emp.active_points);
|
||||||
|
const tier = getTier(emp.active_points);
|
||||||
|
const boundary = nextTierBoundary(emp.active_points);
|
||||||
|
return (
|
||||||
|
<tr key={emp.id} style={{ background: risk ? '#fffdf0' : i % 2 === 0 ? 'white' : '#fafafa' }}>
|
||||||
|
<td style={{ ...s.td, color: '#aaa', fontSize: '12px' }}>{i + 1}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>{emp.name}</button>
|
||||||
|
{risk && (
|
||||||
|
<span style={s.atRiskBadge}>
|
||||||
|
⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...s.td, color: '#666' }}>{emp.department || '—'}</td>
|
||||||
|
<td style={{ ...s.td, color: '#666' }}>{emp.supervisor || '—'}</td>
|
||||||
|
<td style={s.td}><CpasBadge points={emp.active_points} /></td>
|
||||||
|
<td style={{ ...s.td, fontWeight: 700, color: tier.color, fontSize: '16px' }}>{emp.active_points}</td>
|
||||||
|
<td style={{ ...s.td, color: '#666' }}>{emp.violation_count}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Employee profile modal ───────────────────────────── */}
|
||||||
|
{selectedId && (
|
||||||
|
<EmployeeModal
|
||||||
|
employeeId={selectedId}
|
||||||
|
onClose={() => { setSelectedId(null); load(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
client/src/components/EmployeeModal.jsx
Executable file
245
client/src/components/EmployeeModal.jsx
Executable file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import CpasBadge, { getTier } from './CpasBadge';
|
||||||
|
import NegateModal from './NegateModal';
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end' },
|
||||||
|
panel: { background: 'white', width: '680px', maxWidth: '95vw', height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' },
|
||||||
|
header: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', color: 'white', padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10 },
|
||||||
|
closeBtn: { float: 'right', background: 'none', border: 'none', color: 'white', fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px' },
|
||||||
|
body: { padding: '24px 28px', flex: 1 },
|
||||||
|
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
|
||||||
|
scoreCard: { flex: '1', minWidth: '100px', background: '#f8f9fa', borderRadius: '8px', padding: '14px', textAlign: 'center', border: '1px solid #dee2e6' },
|
||||||
|
scoreNum: { fontSize: '26px', fontWeight: 800 },
|
||||||
|
scoreLbl: { fontSize: '11px', color: '#888', marginTop: '3px' },
|
||||||
|
sectionHd: { fontSize: '13px', fontWeight: 700, color: '#34495e', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '10px', marginTop: '24px' },
|
||||||
|
table: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' },
|
||||||
|
th: { background: '#f1f3f5', padding: '8px 10px', textAlign: 'left', color: '#555', fontWeight: 600, fontSize: '11px', textTransform: 'uppercase' },
|
||||||
|
td: { padding: '9px 10px', borderBottom: '1px solid #f0f0f0', verticalAlign: 'top' },
|
||||||
|
negatedRow: { background: '#f8f8f8', color: '#aaa' },
|
||||||
|
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: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' },
|
||||||
|
pdfBtn: { background: 'none', border: '1px solid #667eea', color: '#667eea', borderRadius: '4px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', fontWeight: 600 },
|
||||||
|
deleteConfirm: { background: '#f8d7da', border: '1px solid #f5c6cb', borderRadius: '6px', padding: '12px', marginTop: '8px', fontSize: '12px' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESOLUTION_TYPES = [
|
||||||
|
'Corrective Training Completed',
|
||||||
|
'Management Discretion',
|
||||||
|
'Data Entry Error',
|
||||||
|
'Successfully Appealed',
|
||||||
|
];
|
||||||
|
|
||||||
|
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); // violation object being soft-negated
|
||||||
|
const [confirmDel, setConfirmDel] = useState(null); // violation id pending hard delete
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHardDelete = async (id) => {
|
||||||
|
await axios.delete(`/api/violations/${id}`);
|
||||||
|
setConfirmDel(null);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (id) => {
|
||||||
|
await axios.patch(`/api/violations/${id}/restore`);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tier = score ? getTier(score.active_points) : null;
|
||||||
|
const active = violations.filter(v => !v.negated);
|
||||||
|
const negated = violations.filter(v => v.negated);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div style={s.panel}>
|
||||||
|
|
||||||
|
{/* ── Header ──────────────────────────────────── */}
|
||||||
|
<div style={s.header}>
|
||||||
|
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||||
|
<div style={{ fontSize: '20px', fontWeight: 700 }}>
|
||||||
|
{loading ? 'Loading…' : (employee?.name || 'Employee Profile')}
|
||||||
|
</div>
|
||||||
|
{employee && (
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.75, marginTop: '4px' }}>
|
||||||
|
{[employee.department, employee.supervisor ? `Supervisor: ${employee.supervisor}` : null].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.body}>
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#aaa', textAlign: 'center', paddingTop: '40px' }}>Loading…</p>
|
||||||
|
) : (<>
|
||||||
|
|
||||||
|
{/* ── Score cards ───────────────────────── */}
|
||||||
|
<div style={s.scoreRow}>
|
||||||
|
<div style={{ ...s.scoreCard, borderTop: `3px solid ${tier?.color}` }}>
|
||||||
|
<div style={{ ...s.scoreNum, color: tier?.color }}>{score?.active_points ?? 0}</div>
|
||||||
|
<div style={s.scoreLbl}>Active Points</div>
|
||||||
|
</div>
|
||||||
|
<div style={s.scoreCard}>
|
||||||
|
<div style={s.scoreNum}>{score?.violation_count ?? 0}</div>
|
||||||
|
<div style={s.scoreLbl}>90-Day Violations</div>
|
||||||
|
</div>
|
||||||
|
<div style={s.scoreCard}>
|
||||||
|
<div style={s.scoreNum}>{active.length}</div>
|
||||||
|
<div style={s.scoreLbl}>Total On Record</div>
|
||||||
|
</div>
|
||||||
|
<div style={s.scoreCard}>
|
||||||
|
<div style={{ ...s.scoreNum, color: '#888' }}>{negated.length}</div>
|
||||||
|
<div style={s.scoreLbl}>Negated</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tier && (
|
||||||
|
<div style={{ background: '#f8f9fa', borderRadius: '6px', padding: '10px 14px', marginBottom: '16px', fontSize: '13px', border: `1px solid ${tier.color}33` }}>
|
||||||
|
<strong style={{ color: tier.color }}>{tier.label}</strong>
|
||||||
|
<span style={{ color: '#888', marginLeft: '10px', fontSize: '12px' }}>Rolling 90-day window · Points expire automatically</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Active violations ─────────────────── */}
|
||||||
|
<div style={s.sectionHd}>Active Violations</div>
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<p style={{ color: '#aaa', fontSize: '13px', fontStyle: 'italic' }}>No active violations on record.</p>
|
||||||
|
) : (
|
||||||
|
<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}</div>
|
||||||
|
<div style={{ color: '#888', fontSize: '11px' }}>{v.category}</div>
|
||||||
|
{v.details && <div style={{ color: '#666', fontSize: '11px', marginTop: '3px', fontStyle: 'italic' }}>{v.details}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...s.td, fontWeight: 700, color: '#c0392b' }}>{v.points}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<button style={s.actionBtn('#856404')} onClick={() => setNegating(v)}>⊘ Negate</button>
|
||||||
|
<button style={s.pdfBtn} onClick={() => handleDownloadPdf(v.id, employee?.name, v.incident_date)}>PDF</button>
|
||||||
|
<br />
|
||||||
|
{confirmDel === v.id ? (
|
||||||
|
<div style={s.deleteConfirm}>
|
||||||
|
<strong>Permanently delete?</strong> This cannot be undone.
|
||||||
|
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={s.actionBtn('#c0392b')} onClick={() => handleHardDelete(v.id)}>Confirm Delete</button>
|
||||||
|
<button style={s.actionBtn('#666')} onClick={() => setConfirmDel(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button style={{ ...s.actionBtn('#c0392b'), marginTop: '4px' }} onClick={() => setConfirmDel(v.id)}>✕ Delete</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Negated violations ────────────────── */}
|
||||||
|
{negated.length > 0 && (<>
|
||||||
|
<div style={s.sectionHd}>Negated / Resolved Violations</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={{ textDecoration: 'line-through' }}>{v.violation_name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#aaa' }}>{v.category}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...s.td, textDecoration: 'line-through', color: '#aaa' }}>{v.points}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<span style={s.resTag}>{v.resolution_type}</span>
|
||||||
|
{v.resolution_details && <div style={{ fontSize: '11px', marginTop: '3px', color: '#666' }}>{v.resolution_details}</div>}
|
||||||
|
{v.resolved_by && <div style={{ fontSize: '10px', color: '#aaa' }}>by {v.resolved_by}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<button style={s.actionBtn('#28a745')} onClick={() => handleRestore(v.id)}>↩ Restore</button>
|
||||||
|
{confirmDel === v.id ? (
|
||||||
|
<div style={s.deleteConfirm}>
|
||||||
|
<strong>Permanently delete?</strong>
|
||||||
|
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={s.actionBtn('#c0392b')} onClick={() => handleHardDelete(v.id)}>Confirm</button>
|
||||||
|
<button style={s.actionBtn('#666')} onClick={() => setConfirmDel(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button style={s.actionBtn('#c0392b')} onClick={() => setConfirmDel(v.id)}>✕ Delete</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Negate sub-modal ────────────────────────────────── */}
|
||||||
|
{negating && (
|
||||||
|
<NegateModal
|
||||||
|
violation={negating}
|
||||||
|
onConfirm={async ({ resolution_type, details, resolved_by }) => {
|
||||||
|
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
|
||||||
|
setNegating(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
onCancel={() => setNegating(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/src/components/NegateModal.jsx
Executable file
68
client/src/components/NegateModal.jsx
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const RESOLUTION_TYPES = [
|
||||||
|
'Corrective Training Completed',
|
||||||
|
'Management Discretion',
|
||||||
|
'Data Entry Error',
|
||||||
|
'Successfully Appealed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const s = {
|
||||||
|
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.65)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
box: { background: 'white', borderRadius: '10px', padding: '28px', width: '440px', maxWidth: '95vw', boxShadow: '0 8px 32px rgba(0,0,0,0.22)' },
|
||||||
|
title: { fontSize: '17px', fontWeight: 700, color: '#2c3e50', marginBottom: '6px' },
|
||||||
|
sub: { fontSize: '12px', color: '#888', marginBottom: '20px' },
|
||||||
|
label: { fontWeight: 600, color: '#555', fontSize: '12px', marginBottom: '5px', display: 'block' },
|
||||||
|
input: { width: '100%', padding: '9px 12px', border: '1px solid #ddd', borderRadius: '5px', fontSize: '13px', fontFamily: 'inherit', marginBottom: '14px' },
|
||||||
|
btnRow: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '8px' },
|
||||||
|
btnOk: { padding: '10px 22px', background: '#856404', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 700, fontSize: '13px' },
|
||||||
|
btnCancel:{ padding: '10px 22px', background: '#f1f3f5', color: '#555', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
|
||||||
|
violBox: { background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '6px', padding: '10px 14px', marginBottom: '18px', fontSize: '13px' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||||
|
const [resType, setResType] = useState('');
|
||||||
|
const [details, setDetails] = useState('');
|
||||||
|
const [resolvedBy, setResolvedBy] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!resType) { setError('Please select a resolution type.'); return; }
|
||||||
|
onConfirm({ resolution_type: resType, details, resolved_by: resolvedBy });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.overlay}>
|
||||||
|
<div style={s.box}>
|
||||||
|
<div style={s.title}>⊘ Negate Violation Points</div>
|
||||||
|
<div style={s.sub}>This will zero out the points from this incident. The record remains in the audit log.</div>
|
||||||
|
|
||||||
|
<div style={s.violBox}>
|
||||||
|
<strong>{violation.violation_name}</strong> · {violation.points} pts · {violation.incident_date}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={s.label}>Resolution Type *</label>
|
||||||
|
<select style={s.input} value={resType} onChange={e => { setResType(e.target.value); setError(''); }}>
|
||||||
|
<option value="">-- Select Resolution --</option>
|
||||||
|
{RESOLUTION_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label style={s.label}>Additional Details</label>
|
||||||
|
<textarea style={{ ...s.input, resize: 'vertical', minHeight: '70px' }}
|
||||||
|
placeholder="Training course completed, specific context, approving manager notes…"
|
||||||
|
value={details} onChange={e => setDetails(e.target.value)} />
|
||||||
|
|
||||||
|
<label style={s.label}>Resolved By</label>
|
||||||
|
<input style={s.input} type="text" placeholder="Officer / Manager name"
|
||||||
|
value={resolvedBy} onChange={e => setResolvedBy(e.target.value)} />
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#c0392b', fontSize: '12px', marginBottom: '10px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={s.btnRow}>
|
||||||
|
<button style={s.btnCancel} onClick={onCancel}>Cancel</button>
|
||||||
|
<button style={s.btnOk} onClick={handleSubmit}>Confirm Negation</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,31 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH || '/data/cpas.db';
|
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'cpas.db');
|
||||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
const dbDir = path.dirname(DB_PATH);
|
const db = new Database(dbPath);
|
||||||
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
|
||||||
|
|
||||||
const db = new Database(DB_PATH);
|
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
|
||||||
console.log(`[DB] Connected: ${DB_PATH}`);
|
// Migrate: add negated columns if upgrading from Phase 1-3
|
||||||
|
const cols = db.prepare("PRAGMA table_info(violations)").all().map(c => c.name);
|
||||||
|
if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0");
|
||||||
|
if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME");
|
||||||
|
|
||||||
|
// Ensure resolutions table exists on upgrade
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
|
||||||
|
resolution_type TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
resolved_by TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
|
console.log('[DB] Connected:', dbPath);
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -11,26 +11,35 @@ CREATE TABLE IF NOT EXISTS violations (
|
|||||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
violation_type TEXT NOT NULL,
|
violation_type TEXT NOT NULL,
|
||||||
violation_name TEXT NOT NULL,
|
violation_name TEXT NOT NULL,
|
||||||
category TEXT NOT NULL,
|
category TEXT NOT NULL DEFAULT 'General',
|
||||||
points INTEGER NOT NULL,
|
points INTEGER NOT NULL,
|
||||||
incident_date DATE NOT NULL,
|
incident_date TEXT NOT NULL,
|
||||||
incident_time TEXT,
|
incident_time TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
details TEXT,
|
details TEXT,
|
||||||
submitted_by TEXT,
|
submitted_by TEXT,
|
||||||
witness_name TEXT,
|
witness_name TEXT,
|
||||||
|
negated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
negated_at DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS violation_resolutions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
|
||||||
|
resolution_type TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
resolved_by TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Active score: only non-negated violations in rolling 90 days
|
||||||
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
|
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
|
||||||
SELECT
|
SELECT
|
||||||
e.id AS employee_id,
|
employee_id,
|
||||||
e.name AS employee_name,
|
SUM(points) AS active_points,
|
||||||
e.department,
|
COUNT(*) AS violation_count
|
||||||
COALESCE(SUM(v.points), 0) AS active_points,
|
FROM violations
|
||||||
COUNT(v.id) AS violation_count
|
WHERE negated = 0
|
||||||
FROM employees e
|
AND incident_date >= DATE('now', '-90 days')
|
||||||
LEFT JOIN violations v
|
GROUP BY employee_id;
|
||||||
ON v.employee_id = e.id
|
|
||||||
AND v.incident_date >= DATE('now', '-90 days')
|
|
||||||
GROUP BY e.id;
|
|
||||||
|
|||||||
181
server.js
181
server.js
@@ -11,141 +11,156 @@ app.use(cors());
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
||||||
|
|
||||||
// ── Health ─────────────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Employees ──────────────────────────────────────────────────────────────
|
// ── Employees ───────────────────────────────────────────────────────────────
|
||||||
app.get('/api/employees', (req, res) => {
|
app.get('/api/employees', (req, res) => {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all();
|
||||||
'SELECT id, name, department, supervisor FROM employees ORDER BY name ASC'
|
|
||||||
).all();
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/employees', (req, res) => {
|
app.post('/api/employees', (req, res) => {
|
||||||
const { name, department, supervisor } = req.body;
|
const { name, department, supervisor } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const existing = db.prepare('SELECT * FROM employees WHERE LOWER(name) = LOWER(?)').get(name);
|
||||||
const existing = db.prepare(
|
|
||||||
'SELECT * FROM employees WHERE LOWER(name) = LOWER(?)'
|
|
||||||
).get(name);
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (department || supervisor) {
|
if (department || supervisor)
|
||||||
db.prepare(
|
db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
|
||||||
'UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?'
|
.run(department || null, supervisor || null, existing.id);
|
||||||
).run(department || null, supervisor || null, existing.id);
|
|
||||||
}
|
|
||||||
return res.json({ ...existing, department, supervisor });
|
return res.json({ ...existing, department, supervisor });
|
||||||
}
|
}
|
||||||
|
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)').run(name, department || null, supervisor || null);
|
||||||
const result = db.prepare(
|
|
||||||
'INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)'
|
|
||||||
).run(name, department || null, supervisor || null);
|
|
||||||
|
|
||||||
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Employee CPAS Score ────────────────────────────────────────────────────
|
// ── Employee CPAS Score ─────────────────────────────────────────────────────
|
||||||
app.get('/api/employees/:employeeId/score', (req, res) => {
|
app.get('/api/employees/:id/score', (req, res) => {
|
||||||
const row = db.prepare(
|
const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id);
|
||||||
'SELECT * FROM active_cpas_scores WHERE employee_id = ?'
|
res.json(row || { employee_id: req.params.id, active_points: 0, violation_count: 0 });
|
||||||
).get(req.params.employeeId);
|
|
||||||
res.json(row || { employee_id: req.params.employeeId, active_points: 0, violation_count: 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Violation type counts (90-day) ─────────────────────────────────────────
|
// ── Dashboard — all employees with scores ───────────────────────────────────
|
||||||
app.get('/api/employees/:employeeId/violation-counts', (req, res) => {
|
app.get('/api/dashboard', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT violation_type, COUNT(*) as count
|
SELECT
|
||||||
FROM violations
|
e.id, e.name, e.department, e.supervisor,
|
||||||
WHERE employee_id = ?
|
COALESCE(s.active_points, 0) AS active_points,
|
||||||
AND incident_date >= DATE('now', '-90 days')
|
COALESCE(s.violation_count,0) AS violation_count
|
||||||
|
FROM employees e
|
||||||
|
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
|
||||||
|
ORDER BY active_points DESC, e.name ASC
|
||||||
|
`).all();
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Violation counts (90-day) ───────────────────────────────────────────────
|
||||||
|
app.get('/api/employees/:id/violation-counts', (req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT violation_type, COUNT(*) as count FROM violations
|
||||||
|
WHERE employee_id = ? AND negated = 0 AND incident_date >= DATE('now', '-90 days')
|
||||||
GROUP BY violation_type
|
GROUP BY violation_type
|
||||||
`).all(req.params.employeeId);
|
`).all(req.params.id);
|
||||||
const map = {};
|
const map = {};
|
||||||
rows.forEach(r => { map[r.violation_type] = r.count; });
|
rows.forEach(r => { map[r.violation_type] = r.count; });
|
||||||
res.json(map);
|
res.json(map);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Violation type counts (all-time) ───────────────────────────────────────
|
// ── Violation counts (all-time) ─────────────────────────────────────────────
|
||||||
app.get('/api/employees/:employeeId/violation-counts/alltime', (req, res) => {
|
app.get('/api/employees/:id/violation-counts/alltime', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT violation_type, COUNT(*) as count, MAX(points) as max_points_used
|
SELECT violation_type, COUNT(*) as count, MAX(points) as max_points_used FROM violations
|
||||||
FROM violations
|
WHERE employee_id = ? AND negated = 0
|
||||||
WHERE employee_id = ?
|
|
||||||
GROUP BY violation_type
|
GROUP BY violation_type
|
||||||
`).all(req.params.employeeId);
|
`).all(req.params.id);
|
||||||
const map = {};
|
const map = {};
|
||||||
rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; });
|
rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; });
|
||||||
res.json(map);
|
res.json(map);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Violation history ──────────────────────────────────────────────────────
|
// ── Violation history (per employee) ───────────────────────────────────────
|
||||||
app.get('/api/violations/employee/:employeeId', (req, res) => {
|
app.get('/api/violations/employee/:id', (req, res) => {
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM violations
|
SELECT v.*, r.resolution_type, r.details AS resolution_details,
|
||||||
WHERE employee_id = ?
|
r.resolved_by, r.created_at AS resolved_at
|
||||||
ORDER BY incident_date DESC, created_at DESC
|
FROM violations v
|
||||||
|
LEFT JOIN violation_resolutions r ON r.violation_id = v.id
|
||||||
|
WHERE v.employee_id = ?
|
||||||
|
ORDER BY v.incident_date DESC, v.created_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(req.params.employeeId, limit);
|
`).all(req.params.id, limit);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST new violation ─────────────────────────────────────────────────────
|
// ── POST new violation ──────────────────────────────────────────────────────
|
||||||
app.post('/api/violations', (req, res) => {
|
app.post('/api/violations', (req, res) => {
|
||||||
const {
|
const {
|
||||||
employee_id, violation_type, violation_name, category,
|
employee_id, violation_type, violation_name, category,
|
||||||
points, incident_date, incident_time, location,
|
points, incident_date, incident_time, location,
|
||||||
details, submitted_by, witness_name
|
details, submitted_by, witness_name
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
if (!employee_id || !violation_type || !points || !incident_date)
|
||||||
if (!employee_id || !violation_type || !points || !incident_date) {
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Missing required fields: employee_id, violation_type, points, incident_date'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO violations (
|
INSERT INTO violations (employee_id, violation_type, violation_name, category,
|
||||||
employee_id, violation_type, violation_name, category,
|
points, incident_date, incident_time, location, details, submitted_by, witness_name)
|
||||||
points, incident_date, incident_time, location,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
details, submitted_by, witness_name
|
`).run(employee_id, violation_type, violation_name || violation_type,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
employee_id, violation_type, violation_name || violation_type,
|
|
||||||
category || 'General', points, incident_date,
|
category || 'General', points, incident_date,
|
||||||
incident_time || null, location || null,
|
incident_time || null, location || null,
|
||||||
details || null, submitted_by || null, witness_name || null
|
details || null, submitted_by || null, witness_name || null);
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json({ id: result.lastInsertRowid });
|
res.status(201).json({ id: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PDF Generation ─────────────────────────────────────────────────────────
|
// ── PATCH — Soft Negate (add resolution) ───────────────────────────────────
|
||||||
// GET /api/violations/:id/pdf
|
app.patch('/api/violations/:id/negate', (req, res) => {
|
||||||
// Returns a binary PDF of the violation document
|
const { resolution_type, details, resolved_by } = req.body;
|
||||||
|
if (!resolution_type) return res.status(400).json({ error: 'resolution_type is required' });
|
||||||
|
|
||||||
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE violations SET negated = 1, negated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.params.id);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(req.params.id, resolution_type, details || null, resolved_by || null);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH — Restore negated violation ──────────────────────────────────────
|
||||||
|
app.patch('/api/violations/:id/restore', (req, res) => {
|
||||||
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
db.prepare('UPDATE violations SET negated = 0, negated_at = NULL WHERE id = ?').run(req.params.id);
|
||||||
|
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE — Hard Delete ────────────────────────────────────────────────────
|
||||||
|
app.delete('/api/violations/:id', (req, res) => {
|
||||||
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(req.params.id);
|
||||||
|
db.prepare('DELETE FROM violations WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PDF ─────────────────────────────────────────────────────────────────────
|
||||||
app.get('/api/violations/:id/pdf', async (req, res) => {
|
app.get('/api/violations/:id/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const violation = db.prepare(`
|
const violation = db.prepare(`
|
||||||
SELECT v.*, e.name as employee_name, e.department, e.supervisor
|
SELECT v.*, e.name as employee_name, e.department, e.supervisor
|
||||||
FROM violations v
|
FROM violations v JOIN employees e ON e.id = v.employee_id
|
||||||
JOIN employees e ON e.id = v.employee_id
|
|
||||||
WHERE v.id = ?
|
WHERE v.id = ?
|
||||||
`).get(req.params.id);
|
`).get(req.params.id);
|
||||||
|
|
||||||
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
const score = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(violation.employee_id) || { active_points: 0, violation_count: 0 };
|
||||||
// Pull employee 90-day score for context block in PDF
|
|
||||||
const score = db.prepare(
|
|
||||||
'SELECT * FROM active_cpas_scores WHERE employee_id = ?'
|
|
||||||
).get(violation.employee_id) || { active_points: 0, violation_count: 0 };
|
|
||||||
|
|
||||||
const pdfBuffer = await generatePdf(violation, score);
|
const pdfBuffer = await generatePdf(violation, score);
|
||||||
|
|
||||||
const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_');
|
const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_');
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
@@ -154,16 +169,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
|
|||||||
});
|
});
|
||||||
res.end(pdfBuffer);
|
res.end(pdfBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[PDF] Error:', err);
|
console.error('[PDF]', err);
|
||||||
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
|
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── SPA fallback ───────────────────────────────────────────────────────────
|
// ── SPA fallback ────────────────────────────────────────────────────────────
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')));
|
||||||
res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => console.log(`[CPAS] Server running on port ${PORT}`));
|
||||||
console.log(`[CPAS] Server running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user