diff --git a/client/src/components/CpasBadge.jsx b/client/src/components/CpasBadge.jsx new file mode 100755 index 0000000..21c172a --- /dev/null +++ b/client/src/components/CpasBadge.jsx @@ -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 ( + + {points} pts — {tier.label} + + ); +} diff --git a/client/src/components/TierWarning.jsx b/client/src/components/TierWarning.jsx new file mode 100755 index 0000000..24e32ee --- /dev/null +++ b/client/src/components/TierWarning.jsx @@ -0,0 +1,35 @@ +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 ( +
+ ⚠ Tier Escalation Warning
+ Adding {addingPoints} point{addingPoints !== 1 ? 's' : ''} will move this employee + from {current.label} to {projected.label}. + {tierUp && ( + Tier threshold crossed at {tierUp.min} points. + )} +
+ ); +} diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx index c22f2dc..03344f2 100755 --- a/client/src/components/ViolationForm.jsx +++ b/client/src/components/ViolationForm.jsx @@ -1,6 +1,10 @@ import React, { useState, useEffect } 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'; const s = { content: { padding: '40px' }, @@ -12,12 +16,14 @@ const s = { input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' }, fullCol: { gridColumn: '1 / -1' }, contextBox: { background: '#f1f3f5', border: '1px solid #ced4da', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#444', marginTop: '4px' }, + repeatBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '11px', fontWeight: 700, background: '#fff3cd', color: '#856404', border: '1px solid #ffc107' }, + repeatWarn: { background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#856404' }, pointBox: { background: '#fff3cd', border: '2px solid #ffc107', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' }, - slider: { width: '100%', marginTop: '10px' }, pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#667eea', 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, #667eea 0%, #764ba2 100%)', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' }, - btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: '#6c757d', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' }, + btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', textTransform: 'uppercase' }, + btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: '#6c757d', color: 'white', textTransform: 'uppercase' }, note: { background: '#e7f3ff', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px' }, statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' }, statusErr: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb' }, @@ -35,10 +41,25 @@ export default function ViolationForm() { const [violation, setViolation] = useState(null); const [status, setStatus] = useState(null); + // Phase 2: pull score + history whenever employee changes + const intel = useEmployeeIntelligence(form.employeeId || null); + useEffect(() => { axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {}); }, []); + // When violation type changes, check all-time counts and auto-suggest higher pts for recidivists + useEffect(() => { + if (!violation || !form.violationType) return; + const allTime = intel.countsAllTime[form.violationType]; + if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) { + // Suggest max points for repeat offenders + 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; @@ -57,11 +78,10 @@ export default function ViolationForm() { const handleSubmit = async e => { e.preventDefault(); if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' }); + if (!form.employeeName) return setStatus({ ok: false, msg: 'Please enter an employee name.' }); try { const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); const employeeId = empRes.data.id; - const empList = await axios.get('/api/employees'); - setEmployees(empList.data); await axios.post('/api/violations', { employee_id: employeeId, violation_type: form.violationType, violation_name: violation?.name || form.violationType, @@ -70,6 +90,9 @@ export default function ViolationForm() { location: form.location || null, details: form.additionalDetails || null, witness_name: form.witnessName || null, }); + // Refresh employee list and re-run intel for updated score + const empList = await axios.get('/api/employees'); + setEmployees(empList.data); setStatus({ ok: true, msg: '✓ Violation recorded successfully' }); setForm(EMPTY_FORM); setViolation(null); @@ -78,12 +101,28 @@ export default function ViolationForm() { } }; - const showField = f => violation?.fields?.includes(f); + const showField = f => violation?.fields?.includes(f); + const priorCount90 = key => intel.counts90[key] || 0; + const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1; return (
+ + {/* ── Employee Information ────────────────────────────────── */}

Employee Information

+ + {/* CPAS score banner — shown once employee is selected */} + {intel.score && form.employeeId && ( +
+ Current Standing: + + + {intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days + +
+ )} + {employees.length > 0 && (
@@ -95,6 +134,7 @@ export default function ViolationForm() {
)} +
{[['employeeName','Employee Name','text','John Doe'],['department','Department','text','Engineering'],['supervisor','Supervisor Name','text','Jane Smith'],['witnessName','Witness Name (Officer)','text','Officer Name']].map(([name,label,type,ph]) => (
@@ -105,31 +145,60 @@ export default function ViolationForm() {
+ {/* ── Violation Details ────────────────────────────────────── */}

Violation Details

+ + {/* Violation type dropdown with prior-use badges */}
+ + {/* Handbook definition */} {violation && (
- {violation.name} — {violation.description}
+ {violation.name} + {isRepeat(form.violationType) && form.employeeId && ( + + ★ Repeat — {intel.countsAllTime[form.violationType]?.count}x prior + + )} +
+ {violation.description}
{violation.chapter}
)} + + {/* Recidivist auto-suggest notice */} + {violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && ( +
+ Repeat offense detected. Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed. +
+ )}
+ + {/* Incident date */}
+ {showField('time') && (
@@ -161,6 +230,16 @@ export default function ViolationForm() {
)}
+ + {/* Tier escalation warning */} + {intel.score && violation && ( + + )} + + {/* Point slider */} {violation && (

CPAS Point Assessment

@@ -169,21 +248,40 @@ export default function ViolationForm() { ? `${violation.minPoints} Points (Fixed)` : `${violation.minPoints}–${violation.maxPoints} Points`}

- +
{form.points} Points

Adjust to reflect severity and context

)}
+
Note: Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3.
+
- +
+ {status &&
{status.msg}
}
+ + {/* ── Violation History Panel ──────────────────────────────── */} + {form.employeeId && ( +
+

Violation History

+ +
+ )} +
); } diff --git a/client/src/components/ViolationHistory.jsx b/client/src/components/ViolationHistory.jsx new file mode 100755 index 0000000..711c22d --- /dev/null +++ b/client/src/components/ViolationHistory.jsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; + +const s = { + wrapper: { marginTop: '24px' }, + title: { color: '#2c3e50', fontSize: '16px', fontWeight: 700, marginBottom: '10px' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' }, + th: { background: '#2c3e50', color: 'white', padding: '8px 10px', textAlign: 'left' }, + td: { padding: '8px 10px', borderBottom: '1px solid #dee2e6' }, + trEven: { background: '#f8f9fa' }, + trOdd: { background: 'white' }, + pts: { fontWeight: 700, color: '#667eea' }, + toggle: { background: 'none', border: 'none', color: '#667eea', cursor: 'pointer', fontSize: '13px', padding: 0, textDecoration: 'underline' }, + empty: { color: '#888', 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

Loading history...

; + if (!history.length) return

No violations on record for this employee.

; + + return ( +
+
Recent Violations ({history.length} total)
+ + + + + + + + + + + + {visible.map((v, i) => ( + + + + + + + + ))} + +
DateViolationCategoryPointsDetails
{formatDate(v.incident_date)}{v.violation_name}{v.category}{v.points}{v.details || '—'}
+ {history.length > 5 && ( +
+ +
+ )} +
+ ); +} diff --git a/client/src/hooks/useEmployeeIntelligence.js b/client/src/hooks/useEmployeeIntelligence.js new file mode 100755 index 0000000..14bb1dc --- /dev/null +++ b/client/src/hooks/useEmployeeIntelligence.js @@ -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 }; +} diff --git a/server.js b/server.js index 1343b53..ff4bf33 100755 --- a/server.js +++ b/server.js @@ -10,52 +10,124 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); +// ── Health ───────────────────────────────────────────────────────────────── app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// ── Employees ────────────────────────────────────────────────────────────── app.get('/api/employees', (req, res) => { - const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all(); + const rows = db.prepare( + 'SELECT id, name, department, supervisor FROM employees ORDER BY name ASC' + ).all(); res.json(rows); }); app.post('/api/employees', (req, res) => { const { name, department, supervisor } = req.body; 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 (department || supervisor) { - db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?') - .run(department || null, supervisor || null, existing.id); + db.prepare( + 'UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?' + ).run(department || null, supervisor || null, existing.id); } 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 }); }); +// ── Employee CPAS Score (rolling 90-day) ─────────────────────────────────── +app.get('/api/employees/:employeeId/score', (req, res) => { + const row = db.prepare( + 'SELECT * FROM active_cpas_scores WHERE employee_id = ?' + ).get(req.params.employeeId); + res.json(row || { employee_id: req.params.employeeId, active_points: 0, violation_count: 0 }); +}); + +// ── Violation type usage counts for an employee (90-day window) ──────────── +// Returns { violation_type: count } so the frontend can badge the dropdown +app.get('/api/employees/:employeeId/violation-counts', (req, res) => { + const rows = db.prepare(` + SELECT violation_type, COUNT(*) as count + FROM violations + WHERE employee_id = ? + AND incident_date >= DATE('now', '-90 days') + GROUP BY violation_type + `).all(req.params.employeeId); + + const map = {}; + rows.forEach(r => { map[r.violation_type] = r.count; }); + res.json(map); +}); + +// ── All-time violation type counts for recidivist point suggestion ───────── +app.get('/api/employees/:employeeId/violation-counts/alltime', (req, res) => { + const rows = db.prepare(` + SELECT violation_type, COUNT(*) as count, MAX(points) as max_points_used + FROM violations + WHERE employee_id = ? + GROUP BY violation_type + `).all(req.params.employeeId); + + const map = {}; + rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; }); + res.json(map); +}); + +// ── Violation history for an employee ───────────────────────────────────── app.get('/api/violations/employee/:employeeId', (req, res) => { - const rows = db.prepare('SELECT * FROM violations WHERE employee_id = ? ORDER BY incident_date DESC').all(req.params.employeeId); + const limit = parseInt(req.query.limit) || 50; + const rows = db.prepare(` + SELECT * FROM violations + WHERE employee_id = ? + ORDER BY incident_date DESC, created_at DESC + LIMIT ? + `).all(req.params.employeeId, limit); res.json(rows); }); -app.get('/api/employees/:employeeId/score', (req, res) => { - const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.employeeId); - res.json(row || { active_points: 0, violation_count: 0 }); -}); - +// ── POST new violation ───────────────────────────────────────────────────── app.post('/api/violations', (req, res) => { - const { employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name } = req.body; + const { + employee_id, violation_type, violation_name, category, + points, incident_date, incident_time, location, + details, submitted_by, witness_name + } = req.body; + if (!employee_id || !violation_type || !points || !incident_date) { - return res.status(400).json({ error: 'Missing required fields: employee_id, violation_type, points, incident_date' }); + return res.status(400).json({ + error: 'Missing required fields: employee_id, violation_type, points, incident_date' + }); } + const result = db.prepare(` - INSERT INTO violations (employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(employee_id, violation_type, violation_name || violation_type, category || 'General', points, incident_date, incident_time || null, location || null, details || null, submitted_by || null, witness_name || null); + INSERT INTO violations ( + employee_id, violation_type, violation_name, category, + points, incident_date, incident_time, location, + details, submitted_by, witness_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + employee_id, violation_type, violation_name || violation_type, + category || 'General', points, incident_date, + incident_time || null, location || null, + details || null, submitted_by || null, witness_name || null + ); + res.status(201).json({ id: result.lastInsertRowid }); }); +// ── SPA fallback ─────────────────────────────────────────────────────────── app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')); });