Phase 3
This commit is contained in:
@@ -23,6 +23,7 @@ const s = {
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
@@ -36,24 +37,23 @@ const EMPTY_FORM = {
|
||||
};
|
||||
|
||||
export default function ViolationForm() {
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [violation, setViolation] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [violation, setViolation] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [lastViolId, setLastViolId] = useState(null); // ID of most recently saved violation
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
// 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 }));
|
||||
@@ -82,18 +82,26 @@ export default function ViolationForm() {
|
||||
try {
|
||||
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
|
||||
const employeeId = empRes.data.id;
|
||||
await axios.post('/api/violations', {
|
||||
employee_id: employeeId, violation_type: form.violationType,
|
||||
const violRes = await axios.post('/api/violations', {
|
||||
employee_id: employeeId,
|
||||
violation_type: form.violationType,
|
||||
violation_name: violation?.name || form.violationType,
|
||||
category: violation?.category || 'General', points: parseInt(form.points),
|
||||
incident_date: form.incidentDate, incident_time: form.incidentTime || null,
|
||||
location: form.location || null, details: form.additionalDetails || null,
|
||||
witness_name: form.witnessName || null,
|
||||
category: violation?.category || 'General',
|
||||
points: parseInt(form.points),
|
||||
incident_date: form.incidentDate,
|
||||
incident_time: form.incidentTime || null,
|
||||
location: form.location || null,
|
||||
details: form.additionalDetails || null,
|
||||
witness_name: form.witnessName || null,
|
||||
});
|
||||
// Refresh employee list and re-run intel for updated score
|
||||
|
||||
const newId = violRes.data.id;
|
||||
setLastViolId(newId);
|
||||
|
||||
const empList = await axios.get('/api/employees');
|
||||
setEmployees(empList.data);
|
||||
setStatus({ ok: true, msg: '✓ Violation recorded successfully' });
|
||||
|
||||
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
|
||||
setForm(EMPTY_FORM);
|
||||
setViolation(null);
|
||||
} catch (err) {
|
||||
@@ -101,6 +109,28 @@ export default function ViolationForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!lastViolId) return;
|
||||
setPdfLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/api/violations/${lastViolId}/pdf`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `CPAS_Violation_${lastViolId}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message });
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showField = f => violation?.fields?.includes(f);
|
||||
const priorCount90 = key => intel.counts90[key] || 0;
|
||||
const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1;
|
||||
@@ -108,11 +138,10 @@ export default function ViolationForm() {
|
||||
return (
|
||||
<div style={s.content}>
|
||||
|
||||
{/* ── Employee Information ────────────────────────────────── */}
|
||||
{/* ── 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>
|
||||
@@ -145,13 +174,12 @@ export default function ViolationForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Violation Details ────────────────────────────────────── */}
|
||||
{/* ── 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>
|
||||
@@ -170,7 +198,6 @@ export default function ViolationForm() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Handbook definition */}
|
||||
{violation && (
|
||||
<div style={s.contextBox}>
|
||||
<strong>{violation.name}</strong>
|
||||
@@ -179,13 +206,11 @@ export default function ViolationForm() {
|
||||
★ Repeat — {intel.countsAllTime[form.violationType]?.count}x prior
|
||||
</span>
|
||||
)}
|
||||
<br />
|
||||
{violation.description}<br />
|
||||
<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.
|
||||
@@ -193,7 +218,6 @@ export default function ViolationForm() {
|
||||
)}
|
||||
</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 />
|
||||
@@ -231,7 +255,6 @@ export default function ViolationForm() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tier escalation warning */}
|
||||
{intel.score && violation && (
|
||||
<TierWarning
|
||||
currentPoints={intel.score.active_points}
|
||||
@@ -239,7 +262,6 @@ export default function ViolationForm() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Point slider */}
|
||||
{violation && (
|
||||
<div style={s.pointBox}>
|
||||
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
|
||||
@@ -248,33 +270,43 @@ export default function ViolationForm() {
|
||||
? `${violation.minPoints} Points (Fixed)`
|
||||
: `${violation.minPoints}–${violation.maxPoints} Points`}
|
||||
</p>
|
||||
<input
|
||||
style={{ width: '100%', marginTop: '10px' }}
|
||||
type="range" name="points"
|
||||
<input style={{ width: '100%', marginTop: '10px' }} type="range" name="points"
|
||||
min={violation.minPoints} max={violation.maxPoints}
|
||||
value={form.points} onChange={handleChange}
|
||||
/>
|
||||
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); }}>
|
||||
<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 ──────────────────────────────── */}
|
||||
{/* ── Violation History Panel ──────────────────────────── */}
|
||||
{form.employeeId && (
|
||||
<div style={s.section}>
|
||||
<h2 style={s.sectionTitle}>Violation History</h2>
|
||||
|
||||
Reference in New Issue
Block a user