Merge pull request 'Upload files to "client/src/components"' (#11) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
\
|
||||||
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';
|
||||||
@@ -7,313 +8,307 @@ import TierWarning from './TierWarning';
|
|||||||
import ViolationHistory from './ViolationHistory';
|
import ViolationHistory from './ViolationHistory';
|
||||||
|
|
||||||
const s = {
|
const s = {
|
||||||
content: { padding: '40px' },
|
content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' },
|
||||||
section: { background: '#f8f9fa', borderLeft: '4px solid #667eea', padding: '20px', marginBottom: '30px', borderRadius: '4px' },
|
section: { background: '#181924', borderLeft: '4px solid #d4af37', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
|
||||||
sectionTitle: { color: '#2c3e50', fontSize: '20px', marginBottom: '15px' },
|
sectionTitle: { color: '#f8f9fa', fontSize: '20px', marginBottom: '15px', fontWeight: 700 },
|
||||||
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
|
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
|
||||||
item: { display: 'flex', flexDirection: 'column' },
|
item: { display: 'flex', flexDirection: 'column' },
|
||||||
label: { fontWeight: 600, color: '#555', marginBottom: '5px', fontSize: '13px' },
|
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px' },
|
||||||
input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' },
|
input: { padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa' },
|
||||||
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: '#141623', border: '1px solid #333544', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#d1d3e0', 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' },
|
repeatBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '11px', fontWeight: 700, background: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37' },
|
||||||
repeatWarn: { background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#856404' },
|
repeatWarn: { background: '#3b2e00', border: '1px solid #d4af37', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#ffdf8a' },
|
||||||
pointBox: { background: '#fff3cd', border: '2px solid #ffc107', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
|
pointBox: { background: '#181200', border: '2px solid #d4af37', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
|
||||||
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#667eea', margin: '10px 0' },
|
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#ffd666', margin: '10px 0' },
|
||||||
scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' },
|
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' },
|
btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)', color: '#000', textTransform: 'uppercase' },
|
||||||
btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' },
|
btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 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' },
|
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' },
|
||||||
note: { background: '#e7f3ff', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px' },
|
note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' },
|
||||||
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: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' },
|
||||||
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: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
|
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
|
||||||
violationType: '', incidentDate: '', incidentTime: '',
|
violationType: '', incidentDate: '', incidentTime: '',
|
||||||
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
|
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ViolationForm() {
|
export default function ViolationForm() {
|
||||||
const [employees, setEmployees] = useState([]);
|
const [employees, setEmployees] = useState([]);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const [violation, setViolation] = useState(null);
|
const [violation, setViolation] = useState(null);
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [lastViolId, setLastViolId] = useState(null); // ID of most recently saved violation
|
const [lastViolId, setLastViolId] = useState(null);
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
|
||||||
const intel = useEmployeeIntelligence(form.employeeId || null);
|
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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!violation || !form.violationType) return;
|
if (!violation || !form.violationType) return;
|
||||||
const allTime = intel.countsAllTime[form.violationType];
|
const allTime = intel.countsAllTime[form.violationType];
|
||||||
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
|
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
|
||||||
setForm(prev => ({ ...prev, points: violation.maxPoints }));
|
setForm(prev => ({ ...prev, points: violation.maxPoints }));
|
||||||
} else {
|
} else {
|
||||||
setForm(prev => ({ ...prev, points: violation.minPoints }));
|
setForm(prev => ({ ...prev, points: violation.minPoints }));
|
||||||
}
|
}
|
||||||
}, [form.violationType, violation, intel.countsAllTime]);
|
}, [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;
|
||||||
setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' }));
|
setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViolationChange = e => {
|
const handleViolationChange = e => {
|
||||||
const key = e.target.value;
|
const key = e.target.value;
|
||||||
const v = violationData[key] || null;
|
const v = violationData[key] || null;
|
||||||
setViolation(v);
|
setViolation(v);
|
||||||
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
|
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||||
|
|
||||||
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.' });
|
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 violRes = await axios.post('/api/violations', {
|
const violRes = await axios.post('/api/violations', {
|
||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
violation_type: form.violationType,
|
violation_type: form.violationType,
|
||||||
violation_name: violation?.name || form.violationType,
|
violation_name: violation?.name || form.violationType,
|
||||||
category: violation?.category || 'General',
|
category: violation?.category || 'General',
|
||||||
points: parseInt(form.points),
|
points: parseInt(form.points),
|
||||||
incident_date: form.incidentDate,
|
incident_date: form.incidentDate,
|
||||||
incident_time: form.incidentTime || null,
|
incident_time: form.incidentTime || null,
|
||||||
location: form.location || null,
|
location: form.location || null,
|
||||||
details: form.additionalDetails || null,
|
details: form.additionalDetails || null,
|
||||||
witness_name: form.witnessName || null,
|
witness_name: form.witnessName || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newId = violRes.data.id;
|
const newId = violRes.data.id;
|
||||||
setLastViolId(newId);
|
setLastViolId(newId);
|
||||||
|
|
||||||
const empList = await axios.get('/api/employees');
|
const empList = await axios.get('/api/employees');
|
||||||
setEmployees(empList.data);
|
setEmployees(empList.data);
|
||||||
|
|
||||||
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
|
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
setViolation(null);
|
setViolation(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({ ok: false, msg: '✗ Error: ' + (err.response?.data?.error || err.message) });
|
setStatus({ ok: false, msg: '✗ Error: ' + (err.response?.data?.error || err.message) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPdf = async () => {
|
const handleDownloadPdf = async () => {
|
||||||
if (!lastViolId) return;
|
if (!lastViolId) return;
|
||||||
setPdfLoading(true);
|
setPdfLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/violations/${lastViolId}/pdf`, {
|
const response = await axios.get(`/api/violations/${lastViolId}/pdf`, { responseType: 'blob' });
|
||||||
responseType: 'blob',
|
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||||
});
|
const link = document.createElement('a');
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
link.href = url;
|
||||||
const link = document.createElement('a');
|
link.download = `CPAS_Violation_${lastViolId}.pdf`;
|
||||||
link.href = url;
|
document.body.appendChild(link);
|
||||||
link.download = `CPAS_Violation_${lastViolId}.pdf`;
|
link.click();
|
||||||
document.body.appendChild(link);
|
link.remove();
|
||||||
link.click();
|
window.URL.revokeObjectURL(url);
|
||||||
link.remove();
|
} catch (err) {
|
||||||
window.URL.revokeObjectURL(url);
|
setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message });
|
||||||
} catch (err) {
|
} finally {
|
||||||
setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message });
|
setPdfLoading(false);
|
||||||
} finally {
|
}
|
||||||
setPdfLoading(false);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showField = f => violation?.fields?.includes(f);
|
const showField = f => violation?.fields?.includes(f);
|
||||||
const priorCount90 = key => intel.counts90[key] || 0;
|
const priorCount90 = key => intel.counts90[key] || 0;
|
||||||
const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1;
|
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>
|
|
||||||
|
|
||||||
{intel.score && form.employeeId && (
|
{intel.score && form.employeeId && (
|
||||||
<div style={s.scoreRow}>
|
<div style={s.scoreRow}>
|
||||||
<span style={{ fontSize: '13px', color: '#555', fontWeight: 600 }}>Current Standing:</span>
|
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
|
||||||
<CpasBadge points={intel.score.active_points} />
|
<CpasBadge points={intel.score.active_points} />
|
||||||
<span style={{ fontSize: '12px', color: '#888' }}>
|
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
|
||||||
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
|
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<select style={s.input} onChange={handleEmployeeSelect} value={form.employeeId || ''}>
|
<select style={s.input} onChange={handleEmployeeSelect} value={form.employeeId || ''}>
|
||||||
<option value="">-- Select existing or enter new below --</option>
|
<option value="">-- Select existing or enter new below --</option>
|
||||||
{employees.map(e => (
|
{employees.map(e => (
|
||||||
<option key={e.id} value={e.id}>{e.name}{e.department ? ` — ${e.department}` : ''}</option>
|
<option key={e.id} value={e.id}>{e.name}{e.department ? ` — ${e.department}` : ''}</option>
|
||||||
))}
|
))}
|
||||||
</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}>
|
||||||
<label style={s.label}>{label}:</label>
|
<label style={s.label}>{label}:</label>
|
||||||
<input style={s.input} type={type} name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
|
<input style={s.input} type={type} name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={s.section}>
|
||||||
|
<h2 style={s.sectionTitle}>Violation Details</h2>
|
||||||
|
<div style={s.grid}>
|
||||||
|
|
||||||
|
<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 => {
|
||||||
|
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>
|
||||||
|
|
||||||
|
{violation && (
|
||||||
|
<div style={s.contextBox}>
|
||||||
|
<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: '#a0a3ba' }}>{violation.chapter}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
{/* ── Violation Details ────────────────────────────────── */}
|
<div style={s.item}>
|
||||||
<form onSubmit={handleSubmit}>
|
<label style={s.label}>Incident Date:</label>
|
||||||
<div style={s.section}>
|
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
|
||||||
<h2 style={s.sectionTitle}>Violation Details</h2>
|
</div>
|
||||||
<div style={s.grid}>
|
|
||||||
|
|
||||||
<div style={{ ...s.item, ...s.fullCol }}>
|
{showField('time') && (
|
||||||
<label style={s.label}>Violation Type:</label>
|
<div style={s.item}>
|
||||||
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
|
<label style={s.label}>Incident Time:</label>
|
||||||
<option value="">-- Select Violation Type --</option>
|
<input style={s.input} type="time" name="incidentTime" value={form.incidentTime} onChange={handleChange} />
|
||||||
{Object.entries(violationGroups).map(([group, items]) => (
|
</div>
|
||||||
<optgroup key={group} label={group}>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{violation && (
|
|
||||||
<div style={s.contextBox}>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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 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>
|
|
||||||
<input style={s.input} type="time" name="incidentTime" value={form.incidentTime} onChange={handleChange} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showField('minutes') && (
|
|
||||||
<div style={s.item}>
|
|
||||||
<label style={s.label}>Minutes Late:</label>
|
|
||||||
<input style={s.input} type="number" name="minutesLate" value={form.minutesLate} onChange={handleChange} placeholder="15" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showField('amount') && (
|
|
||||||
<div style={s.item}>
|
|
||||||
<label style={s.label}>Amount / Value:</label>
|
|
||||||
<input style={s.input} type="text" name="amount" value={form.amount} onChange={handleChange} placeholder="$150.00" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showField('location') && (
|
|
||||||
<div style={{ ...s.item, ...s.fullCol }}>
|
|
||||||
<label style={s.label}>Location / Context:</label>
|
|
||||||
<input style={s.input} type="text" name="location" value={form.location} onChange={handleChange} placeholder="Office, vehicle, facility area, etc." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showField('description') && (
|
|
||||||
<div style={{ ...s.item, ...s.fullCol }}>
|
|
||||||
<label style={s.label}>Additional Details:</label>
|
|
||||||
<textarea style={{ ...s.input, resize: 'vertical', minHeight: '80px' }} name="additionalDetails" value={form.additionalDetails} onChange={handleChange} placeholder="Provide specific context, observations, or details..." />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{intel.score && violation && (
|
|
||||||
<TierWarning
|
|
||||||
currentPoints={intel.score.active_points}
|
|
||||||
addingPoints={parseInt(form.points) || 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{violation && (
|
|
||||||
<div style={s.pointBox}>
|
|
||||||
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
{violation.name}: {violation.minPoints === violation.maxPoints
|
|
||||||
? `${violation.minPoints} Points (Fixed)`
|
|
||||||
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
|
||||||
</p>
|
|
||||||
<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.btnRow}>
|
|
||||||
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
|
|
||||||
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); setLastViolId(null); }}>
|
|
||||||
Clear Form
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF download — appears after successful submission */}
|
|
||||||
{lastViolId && status?.ok && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={{ ...s.btnPdf, opacity: pdfLoading ? 0.7 : 1 }}
|
|
||||||
onClick={handleDownloadPdf}
|
|
||||||
disabled={pdfLoading}
|
|
||||||
>
|
|
||||||
{pdfLoading ? '⏳ Generating PDF...' : '⬇ Download PDF'}
|
|
||||||
</button>
|
|
||||||
<p style={{ fontSize: '11px', color: '#888', marginTop: '6px' }}>
|
|
||||||
Violation #{lastViolId} — click to download the signed violation document
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
{showField('minutes') && (
|
||||||
|
<div style={s.item}>
|
||||||
|
<label style={s.label}>Minutes Late:</label>
|
||||||
|
<input style={s.input} type="number" name="minutesLate" value={form.minutesLate} onChange={handleChange} placeholder="15" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showField('amount') && (
|
||||||
|
<div style={s.item}>
|
||||||
|
<label style={s.label}>Amount / Value:</label>
|
||||||
|
<input style={s.input} type="text" name="amount" value={form.amount} onChange={handleChange} placeholder="$150.00" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showField('location') && (
|
||||||
|
<div style={{ ...s.item, ...s.fullCol }}>
|
||||||
|
<label style={s.label}>Location / Context:</label>
|
||||||
|
<input style={s.input} type="text" name="location" value={form.location} onChange={handleChange} placeholder="Office, vehicle, facility area, etc." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showField('description') && (
|
||||||
|
<div style={{ ...s.item, ...s.fullCol }}>
|
||||||
|
<label style={s.label}>Additional Details:</label>
|
||||||
|
<textarea style={{ ...s.input, resize: 'vertical', minHeight: '80px' }} name="additionalDetails" value={form.additionalDetails} onChange={handleChange} placeholder="Provide specific context, observations, or details..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{intel.score && violation && (
|
||||||
|
<TierWarning
|
||||||
|
currentPoints={intel.score.active_points}
|
||||||
|
addingPoints={parseInt(form.points) || 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{violation && (
|
||||||
|
<div style={s.pointBox}>
|
||||||
|
<h4 style={{ color: '#ffdf8a', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
{violation.name}: {violation.minPoints === violation.maxPoints
|
||||||
|
? `${violation.minPoints} Points (Fixed)`
|
||||||
|
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
||||||
|
</p>
|
||||||
|
<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: '#d1d3e0' }}>Adjust to reflect severity and context</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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); setLastViolId(null); }}>
|
||||||
|
Clear Form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastViolId && status?.ok && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ ...s.btnPdf, opacity: pdfLoading ? 0.7 : 1 }}
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={pdfLoading}
|
||||||
|
>
|
||||||
|
{pdfLoading ? '⏳ Generating PDF...' : '⬇ Download PDF'}
|
||||||
|
</button>
|
||||||
|
<p style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '6px' }}>
|
||||||
|
Violation #{lastViolId} — click to download the signed violation document
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{form.employeeId && (
|
||||||
|
<div style={s.section}>
|
||||||
|
<h2 style={s.sectionTitle}>Violation History</h2>
|
||||||
|
<ViolationHistory history={intel.history} loading={intel.loading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user