This commit is contained in:
2026-03-06 12:02:52 -06:00
parent 45d785964d
commit 13f6c9d164
6 changed files with 372 additions and 26 deletions

View File

@@ -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 (
<div style={s.content}>
{/* ── Employee Information ────────────────────────────────── */}
<div style={s.section}>
<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 && (
<div style={{ marginBottom: '12px' }}>
<label style={s.label}>Quick-Select Existing Employee:</label>
@@ -95,6 +134,7 @@ export default function ViolationForm() {
</select>
</div>
)}
<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]) => (
<div key={name} style={s.item}>
@@ -105,31 +145,60 @@ export default function ViolationForm() {
</div>
</div>
{/* ── Violation Details ────────────────────────────────────── */}
<form onSubmit={handleSubmit}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Violation Details</h2>
<div style={s.grid}>
{/* Violation type dropdown with prior-use badges */}
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
<option value="">-- Select Violation Type --</option>
{Object.entries(violationGroups).map(([group, items]) => (
<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>
))}
</select>
{/* Handbook definition */}
{violation && (
<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>
</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>
{/* Incident date */}
<div style={s.item}>
<label style={s.label}>Incident Date:</label>
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
</div>
{showField('time') && (
<div style={s.item}>
<label style={s.label}>Incident Time:</label>
@@ -161,6 +230,16 @@ export default function ViolationForm() {
</div>
)}
</div>
{/* Tier escalation warning */}
{intel.score && violation && (
<TierWarning
currentPoints={intel.score.active_points}
addingPoints={parseInt(form.points) || 0}
/>
)}
{/* Point slider */}
{violation && (
<div style={s.pointBox}>
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
@@ -169,21 +248,40 @@ export default function ViolationForm() {
? `${violation.minPoints} Points (Fixed)`
: `${violation.minPoints}${violation.maxPoints} Points`}
</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>
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
</div>
)}
</div>
<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.
</div>
<div style={s.btnRow}>
<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>
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
</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>
);
}