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 (
+
+
+ {/* ── 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 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)
+
+
+
+ | Date |
+ Violation |
+ Category |
+ Points |
+ Details |
+
+
+
+ {visible.map((v, i) => (
+
+ | {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'));
});