From 333cad41d7929a5d959a1f2c3b96b5c52ef2378d Mon Sep 17 00:00:00 2001 From: Jason UNRAID Date: Fri, 6 Mar 2026 12:53:40 -0600 Subject: [PATCH] Phase 4 --- client/src/App.jsx | 77 +++----- client/src/components/Dashboard.jsx | 173 +++++++++++++++++ client/src/components/EmployeeModal.jsx | 245 ++++++++++++++++++++++++ client/src/components/NegateModal.jsx | 68 +++++++ db/database.js | 29 ++- db/schema.sql | 51 +++-- server.js | 197 ++++++++++--------- 7 files changed, 671 insertions(+), 169 deletions(-) create mode 100755 client/src/components/Dashboard.jsx create mode 100755 client/src/components/EmployeeModal.jsx create mode 100755 client/src/components/NegateModal.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 7392d7f..683e43d 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,56 +1,39 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import ViolationForm from './components/ViolationForm'; +import Dashboard from './components/Dashboard'; -const styles = { - body: { - fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - minHeight: '100vh', - padding: '20px', - margin: 0, - }, - container: { - maxWidth: '1200px', - margin: '0 auto', - background: 'white', - borderRadius: '12px', - boxShadow: '0 20px 60px rgba(0,0,0,0.3)', - overflow: 'hidden', - }, - header: { - background: 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)', - color: 'white', - padding: '30px', - textAlign: 'center', - }, - statusBar: { - fontSize: '11px', - color: '#aaa', - marginTop: '6px', - } +const tabs = [ + { id: 'dashboard', label: 'πŸ“Š Dashboard' }, + { id: 'violation', label: '+ New Violation' }, +]; + +const s = { + 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 }, + logo: { color: 'white', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px', marginRight: '32px', padding: '18px 0' }, + tab: (active) => ({ + padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)', + borderBottom: active ? '3px solid #667eea' : '3px solid transparent', + cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', + background: 'none', border: 'none', borderBottom: active ? '3px solid #667eea' : '3px solid transparent', + }), + card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }, }; export default function App() { - const [apiStatus, setApiStatus] = useState('checking...'); - - useEffect(() => { - fetch('/api/health') - .then(r => r.json()) - .then(() => setApiStatus('● API connected')) - .catch(() => setApiStatus('⚠ API unreachable')); - }, []); - + const [tab, setTab] = useState('dashboard'); return ( -
-
-
-

CPAS Violation Documentation System

-

- Generate Individual Violation Records with Contextual Fields -

-

{apiStatus}

-
- +
+ +
+ {tab === 'dashboard' ? : }
); diff --git a/client/src/components/Dashboard.jsx b/client/src/components/Dashboard.jsx new file mode 100755 index 0000000..33f6b81 --- /dev/null +++ b/client/src/components/Dashboard.jsx @@ -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 ( +
+
+
+
Company Dashboard
+
Click any employee name to view their full profile
+
+
+ setSearch(e.target.value)} /> + +
+
+ + {/* ── Stat cards ───────────────────────────────────────── */} +
+
+
{employees.length}
+
Total Employees
+
+
+
{cleanCount}
+
Elite Standing (0 pts)
+
+
+
{activeCount}
+
With Active Points
+
+
+
{atRiskCount}
+
At Risk (≀{AT_RISK_THRESHOLD} pts to next tier)
+
+
+
{maxPoints}
+
Highest Active Score
+
+
+ + {/* ── Scoreboard table ─────────────────────────────────── */} + {loading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map((emp, i) => { + const risk = isAtRisk(emp.active_points); + const tier = getTier(emp.active_points); + const boundary = nextTierBoundary(emp.active_points); + return ( + + + + + + + + + + ); + })} + +
#EmployeeDepartmentSupervisorTier / StandingActive Points90-Day Violations
No employees found.
{i + 1} + + {risk && ( + + ⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('β€”')[0].trim()} + + )} + {emp.department || 'β€”'}{emp.supervisor || 'β€”'}{emp.active_points}{emp.violation_count}
+ )} + + {/* ── Employee profile modal ───────────────────────────── */} + {selectedId && ( + { setSelectedId(null); load(); }} + /> + )} +
+ ); +} diff --git a/client/src/components/EmployeeModal.jsx b/client/src/components/EmployeeModal.jsx new file mode 100755 index 0000000..ac4b334 --- /dev/null +++ b/client/src/components/EmployeeModal.jsx @@ -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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+ + {/* ── Header ──────────────────────────────────── */} +
+ +
+ {loading ? 'Loading…' : (employee?.name || 'Employee Profile')} +
+ {employee && ( +
+ {[employee.department, employee.supervisor ? `Supervisor: ${employee.supervisor}` : null].filter(Boolean).join(' Β· ')} +
+ )} +
+ +
+ {loading ? ( +

Loading…

+ ) : (<> + + {/* ── Score cards ───────────────────────── */} +
+
+
{score?.active_points ?? 0}
+
Active Points
+
+
+
{score?.violation_count ?? 0}
+
90-Day Violations
+
+
+
{active.length}
+
Total On Record
+
+
+
{negated.length}
+
Negated
+
+
+ + {tier && ( +
+ {tier.label} + Rolling 90-day window Β· Points expire automatically +
+ )} + + {/* ── Active violations ─────────────────── */} +
Active Violations
+ {active.length === 0 ? ( +

No active violations on record.

+ ) : ( + + + + + + + + + + + {active.map(v => ( + + + + + + + ))} + +
DateViolationPtsActions
{v.incident_date} +
{v.violation_name}
+
{v.category}
+ {v.details &&
{v.details}
} +
{v.points} + + +
+ {confirmDel === v.id ? ( +
+ Permanently delete? This cannot be undone. +
+ + +
+
+ ) : ( + + )} +
+ )} + + {/* ── Negated violations ────────────────── */} + {negated.length > 0 && (<> +
Negated / Resolved Violations
+ + + + + + + + + + + + {negated.map(v => ( + + + + + + + + ))} + +
DateViolationPtsResolutionActions
{v.incident_date} +
{v.violation_name}
+
{v.category}
+
{v.points} + {v.resolution_type} + {v.resolution_details &&
{v.resolution_details}
} + {v.resolved_by &&
by {v.resolved_by}
} +
+ + {confirmDel === v.id ? ( +
+ Permanently delete? +
+ + +
+
+ ) : ( + + )} +
+ )} + + )} +
+
+ + {/* ── Negate sub-modal ────────────────────────────────── */} + {negating && ( + { + await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); + setNegating(null); + load(); + }} + onCancel={() => setNegating(null)} + /> + )} +
+ ); +} diff --git a/client/src/components/NegateModal.jsx b/client/src/components/NegateModal.jsx new file mode 100755 index 0000000..1ea38a1 --- /dev/null +++ b/client/src/components/NegateModal.jsx @@ -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 ( +
+
+
⊘ Negate Violation Points
+
This will zero out the points from this incident. The record remains in the audit log.
+ +
+ {violation.violation_name}  Β·  {violation.points} pts  Β·  {violation.incident_date} +
+ + + + + +