Merge pull request 'Phase 2' (#1) from p2 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
38
client/src/components/CpasBadge.jsx
Executable file
38
client/src/components/CpasBadge.jsx
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745', bg: '#d4edda' },
|
||||||
|
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404', bg: '#fff3cd' },
|
||||||
|
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f', bg: '#f8d7da' },
|
||||||
|
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f', bg: '#f8d7da' },
|
||||||
|
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#721c24', bg: '#f5c6cb' },
|
||||||
|
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#721c24', bg: '#f5c6cb' },
|
||||||
|
{ min: 30, max: 999,label: 'Tier 6 — Separation', color: '#fff', bg: '#721c24' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTier(points) {
|
||||||
|
return TIERS.find(t => points >= t.min && points <= t.max) || TIERS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextTier(points) {
|
||||||
|
const idx = TIERS.findIndex(t => points >= t.min && points <= t.max);
|
||||||
|
return idx >= 0 && idx < TIERS.length - 1 ? TIERS[idx + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CpasBadge({ points }) {
|
||||||
|
const tier = getTier(points);
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: tier.color,
|
||||||
|
background: tier.bg,
|
||||||
|
border: `1px solid ${tier.color}`,
|
||||||
|
}}>
|
||||||
|
{points} pts — {tier.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/src/components/TierWarning.jsx
Executable file
35
client/src/components/TierWarning.jsx
Executable file
@@ -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 (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff3cd',
|
||||||
|
border: '2px solid #ffc107',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
margin: '12px 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}>
|
||||||
|
<strong>⚠ Tier Escalation Warning</strong><br />
|
||||||
|
Adding <strong>{addingPoints} point{addingPoints !== 1 ? 's' : ''}</strong> will move this employee
|
||||||
|
from <strong>{current.label}</strong> to <strong>{projected.label}</strong>.
|
||||||
|
{tierUp && (
|
||||||
|
<span> Tier threshold crossed at <strong>{tierUp.min} points</strong>.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { violationData, violationGroups } from '../data/violations';
|
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 = {
|
const s = {
|
||||||
content: { padding: '40px' },
|
content: { padding: '40px' },
|
||||||
@@ -12,12 +16,14 @@ const s = {
|
|||||||
input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' },
|
input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' },
|
||||||
fullCol: { gridColumn: '1 / -1' },
|
fullCol: { gridColumn: '1 / -1' },
|
||||||
contextBox: { background: '#f1f3f5', border: '1px solid #ced4da', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#444', marginTop: '4px' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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', letterSpacing: '0.5px' },
|
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' },
|
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' },
|
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' },
|
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 [violation, setViolation] = useState(null);
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
// Phase 2: pull score + history whenever employee changes
|
||||||
|
const intel = useEmployeeIntelligence(form.employeeId || null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
|
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 handleEmployeeSelect = e => {
|
||||||
const emp = employees.find(x => x.id === parseInt(e.target.value));
|
const emp = employees.find(x => x.id === parseInt(e.target.value));
|
||||||
if (!emp) return;
|
if (!emp) return;
|
||||||
@@ -57,11 +78,10 @@ export default function ViolationForm() {
|
|||||||
const handleSubmit = async e => {
|
const handleSubmit = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' });
|
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 {
|
try {
|
||||||
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
|
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
|
||||||
const employeeId = empRes.data.id;
|
const employeeId = empRes.data.id;
|
||||||
const empList = await axios.get('/api/employees');
|
|
||||||
setEmployees(empList.data);
|
|
||||||
await axios.post('/api/violations', {
|
await axios.post('/api/violations', {
|
||||||
employee_id: employeeId, violation_type: form.violationType,
|
employee_id: employeeId, violation_type: form.violationType,
|
||||||
violation_name: violation?.name || form.violationType,
|
violation_name: violation?.name || form.violationType,
|
||||||
@@ -70,6 +90,9 @@ export default function ViolationForm() {
|
|||||||
location: form.location || null, details: form.additionalDetails || null,
|
location: form.location || null, details: form.additionalDetails || null,
|
||||||
witness_name: form.witnessName || 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' });
|
setStatus({ ok: true, msg: '✓ Violation recorded successfully' });
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
setViolation(null);
|
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 (
|
return (
|
||||||
<div style={s.content}>
|
<div style={s.content}>
|
||||||
|
|
||||||
|
{/* ── Employee Information ────────────────────────────────── */}
|
||||||
<div style={s.section}>
|
<div style={s.section}>
|
||||||
<h2 style={s.sectionTitle}>Employee Information</h2>
|
<h2 style={s.sectionTitle}>Employee Information</h2>
|
||||||
|
|
||||||
|
{/* CPAS score banner — shown once employee is selected */}
|
||||||
|
{intel.score && form.employeeId && (
|
||||||
|
<div style={s.scoreRow}>
|
||||||
|
<span style={{ fontSize: '13px', color: '#555', fontWeight: 600 }}>Current Standing:</span>
|
||||||
|
<CpasBadge points={intel.score.active_points} />
|
||||||
|
<span style={{ fontSize: '12px', color: '#888' }}>
|
||||||
|
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{employees.length > 0 && (
|
{employees.length > 0 && (
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<label style={s.label}>Quick-Select Existing Employee:</label>
|
<label style={s.label}>Quick-Select Existing Employee:</label>
|
||||||
@@ -95,6 +134,7 @@ export default function ViolationForm() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={s.grid}>
|
<div style={s.grid}>
|
||||||
{[['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]) => (
|
{[['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]) => (
|
||||||
<div key={name} style={s.item}>
|
<div key={name} style={s.item}>
|
||||||
@@ -105,31 +145,60 @@ export default function ViolationForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Violation Details ────────────────────────────────────── */}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={s.section}>
|
<div style={s.section}>
|
||||||
<h2 style={s.sectionTitle}>Violation Details</h2>
|
<h2 style={s.sectionTitle}>Violation Details</h2>
|
||||||
<div style={s.grid}>
|
<div style={s.grid}>
|
||||||
|
|
||||||
|
{/* Violation type dropdown with prior-use badges */}
|
||||||
<div style={{ ...s.item, ...s.fullCol }}>
|
<div style={{ ...s.item, ...s.fullCol }}>
|
||||||
<label style={s.label}>Violation Type:</label>
|
<label style={s.label}>Violation Type:</label>
|
||||||
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
|
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
|
||||||
<option value="">-- Select Violation Type --</option>
|
<option value="">-- Select Violation Type --</option>
|
||||||
{Object.entries(violationGroups).map(([group, items]) => (
|
{Object.entries(violationGroups).map(([group, items]) => (
|
||||||
<optgroup key={group} label={group}>
|
<optgroup key={group} label={group}>
|
||||||
{items.map(v => <option key={v.key} value={v.key}>{v.name}</option>)}
|
{items.map(v => {
|
||||||
|
const prior = priorCount90(v.key);
|
||||||
|
return (
|
||||||
|
<option key={v.key} value={v.key}>
|
||||||
|
{v.name}{prior > 0 ? ` ★ ${prior}x in 90 days` : ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* Handbook definition */}
|
||||||
{violation && (
|
{violation && (
|
||||||
<div style={s.contextBox}>
|
<div style={s.contextBox}>
|
||||||
<strong>{violation.name}</strong> — {violation.description}<br />
|
<strong>{violation.name}</strong>
|
||||||
|
{isRepeat(form.violationType) && form.employeeId && (
|
||||||
|
<span style={s.repeatBadge}>
|
||||||
|
★ Repeat — {intel.countsAllTime[form.violationType]?.count}x prior
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
{violation.description}<br />
|
||||||
<span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span>
|
<span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Recidivist auto-suggest notice */}
|
||||||
|
{violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
|
||||||
|
<div style={s.repeatWarn}>
|
||||||
|
<strong>Repeat offense detected.</strong> Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Incident date */}
|
||||||
<div style={s.item}>
|
<div style={s.item}>
|
||||||
<label style={s.label}>Incident Date:</label>
|
<label style={s.label}>Incident Date:</label>
|
||||||
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
|
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showField('time') && (
|
{showField('time') && (
|
||||||
<div style={s.item}>
|
<div style={s.item}>
|
||||||
<label style={s.label}>Incident Time:</label>
|
<label style={s.label}>Incident Time:</label>
|
||||||
@@ -161,6 +230,16 @@ export default function ViolationForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tier escalation warning */}
|
||||||
|
{intel.score && violation && (
|
||||||
|
<TierWarning
|
||||||
|
currentPoints={intel.score.active_points}
|
||||||
|
addingPoints={parseInt(form.points) || 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Point slider */}
|
||||||
{violation && (
|
{violation && (
|
||||||
<div style={s.pointBox}>
|
<div style={s.pointBox}>
|
||||||
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
||||||
@@ -169,21 +248,40 @@ export default function ViolationForm() {
|
|||||||
? `${violation.minPoints} Points (Fixed)`
|
? `${violation.minPoints} Points (Fixed)`
|
||||||
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
||||||
</p>
|
</p>
|
||||||
<input style={s.slider} type="range" name="points" min={violation.minPoints} max={violation.maxPoints} value={form.points} onChange={handleChange} />
|
<input
|
||||||
|
style={{ width: '100%', marginTop: '10px' }}
|
||||||
|
type="range" name="points"
|
||||||
|
min={violation.minPoints} max={violation.maxPoints}
|
||||||
|
value={form.points} onChange={handleChange}
|
||||||
|
/>
|
||||||
<div style={s.pointValue}>{form.points} Points</div>
|
<div style={s.pointValue}>{form.points} Points</div>
|
||||||
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
|
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={s.note}>
|
<div style={s.note}>
|
||||||
<strong>Note:</strong> Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3.
|
<strong>Note:</strong> Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={s.btnRow}>
|
<div style={s.btnRow}>
|
||||||
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
|
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
|
||||||
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); }}>Clear Form</button>
|
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); }}>
|
||||||
|
Clear Form
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
|
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* ── Violation History Panel ──────────────────────────────── */}
|
||||||
|
{form.employeeId && (
|
||||||
|
<div style={s.section}>
|
||||||
|
<h2 style={s.sectionTitle}>Violation History</h2>
|
||||||
|
<ViolationHistory history={intel.history} loading={intel.loading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
63
client/src/components/ViolationHistory.jsx
Executable file
63
client/src/components/ViolationHistory.jsx
Executable file
@@ -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 <p style={s.empty}>Loading history...</p>;
|
||||||
|
if (!history.length) return <p style={s.empty}>No violations on record for this employee.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.wrapper}>
|
||||||
|
<div style={s.title}>Recent Violations ({history.length} total)</div>
|
||||||
|
<table style={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={s.th}>Date</th>
|
||||||
|
<th style={s.th}>Violation</th>
|
||||||
|
<th style={s.th}>Category</th>
|
||||||
|
<th style={s.th}>Points</th>
|
||||||
|
<th style={s.th}>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visible.map((v, i) => (
|
||||||
|
<tr key={v.id} style={i % 2 === 0 ? s.trEven : s.trOdd}>
|
||||||
|
<td style={s.td}>{formatDate(v.incident_date)}</td>
|
||||||
|
<td style={s.td}>{v.violation_name}</td>
|
||||||
|
<td style={s.td}>{v.category}</td>
|
||||||
|
<td style={{ ...s.td, ...s.pts }}>{v.points}</td>
|
||||||
|
<td style={s.td}>{v.details || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{history.length > 5 && (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<button style={s.toggle} onClick={() => setExpanded(e => !e)}>
|
||||||
|
{expanded ? '▲ Show less' : `▼ Show all ${history.length} violations`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
client/src/hooks/useEmployeeIntelligence.js
Executable file
40
client/src/hooks/useEmployeeIntelligence.js
Executable file
@@ -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 };
|
||||||
|
}
|
||||||
104
server.js
104
server.js
@@ -10,52 +10,124 @@ 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 ─────────────────────────────────────────────────────────────────
|
||||||
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 ──────────────────────────────────────────────────────────────
|
||||||
app.get('/api/employees', (req, res) => {
|
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);
|
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('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
|
db.prepare(
|
||||||
.run(department || null, supervisor || null, existing.id);
|
'UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE 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 (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) => {
|
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);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/employees/:employeeId/score', (req, res) => {
|
// ── POST new violation ─────────────────────────────────────────────────────
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/violations', (req, res) => {
|
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) {
|
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(`
|
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)
|
INSERT INTO violations (
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
employee_id, violation_type, violation_name, category,
|
||||||
`).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);
|
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 });
|
res.status(201).json({ id: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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'));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user