feature/department-dropdown #31

Closed
jason wants to merge 3 commits from refs/pull/31/head into main
pull from: refs/pull/31/head
3 changed files with 12 additions and 532 deletions

No files matched your search

+1 -189
View File
@@ -1,189 +1 @@
import React, { useState, useEffect } from 'react'; aW1wb3J0IFJlYWN0LCB7IHVzZVN0YXRlLCB1c2VFZmZlY3QgfSBmcm9tICdyZWFjdCc7CmltcG9ydCBheGlvcyBmcm9tICdheGlvcyc7CmltcG9ydCB7IERFUEFSVE1FTlRTIH0gZnJvbSAnLi4vZGF0YS9kZXBhcnRtZW50cyc7Cgpjb25zdCBzID0gewogIG92ZXJsYXk6IHsKICAgIHBvc2l0aW9uOiAnZml4ZWQnLCBpbnNldDogMCwgYmFja2dyb3VuZDogJ3JnYmEoMCwwLDAsMC44KScsCiAgICB6SW5kZXg6IDIwMDAsIGRpc3BsYXk6ICdmbGV4JywgYWxpZ25JdGVtczogJ2NlbnRlcicsIGp1c3RpZnlDb250ZW50OiAnY2VudGVyJywKICB9LAogIG1vZGFsOiB7CiAgICBiYWNrZ3JvdW5kOiAnIzExMTIxNycsIGNvbG9yOiAnI2Y4ZjlmYScsIHdpZHRoOiAnNDgwcHgnLCBtYXhXaWR0aDogJzk1dncnLAogICAgYm9yZGVyUmFkaXVzOiAnMTBweCcsIGJveFNoYWRvdzogJzAgOHB4IDQwcHggcmdiYSgwLDAsMCwwLjgpJywKICAgIGJvcmRlcjogJzFweCBzb2xpZCAjMjIyJywgb3ZlcmZsb3c6ICdoaWRkZW4nLAogIH0sCiAgaGVhZGVyOiB7CiAgICBiYWNrZ3JvdW5kOiAnbGluZWFyLWdyYWRpZW50KDEzNWRlZywgIzAwMDAwMCwgIzE1MTYyMiknLCBjb2xvcjogJ3doaXRlJywKICAgIHBhZGRpbmc6ICcxOHB4IDIycHgnLCBkaXNwbGF5OiAnZmxleCcsIGFsaWduSXRlbXM6ICdjZW50ZXInLCBqdXN0aWZ5Q29udGVudDogJ3NwYWNlLWJldHdlZW4nLAogICAgYm9yZGVyQm90dG9tOiAnMXB4IHNvbGlkICMyMjInLAogIH0sCiAgdGl0bGU6IHsgZm9udFNpemU6ICcxNXB4JywgZm9udFdlaWdodDogNzAwIH0sCiAgY2xvc2VCdG46IHsKICAgIGJhY2tncm91bmQ6ICdub25lJywgYm9yZGVyOiAnbm9uZScsIGNvbG9yOiAnd2hpdGUnLCBmb250U2l6ZTogJzIwcHgnLAogICAgY3Vyc29yOiAncG9pbnRlcicsIGxpbmVIZWlnaHQ6IDEsCiAgfSwKICBib2R5OiB7IHBhZGRpbmc6ICcyMnB4JyB9LAogIHRhYnM6IHsgZGlzcGxheTogJ2ZsZXgnLCBnYXA6ICc0cHgnLCBtYXJnaW5Cb3R0b206ICcyMHB4JyB9LAogIHRhYjogKGFjdGl2ZSkgPT4gKHsKICAgIGZsZXg6IDEsIHBhZGRpbmc6ICc4cHgnLCBib3JkZXJSYWRpdXM6ICc2cHgnLCBjdXJzb3I6ICdwb2ludGVyJywgZm9udFNpemU6ICcxMnB4JywKICAgIGZvbnRXZWlnaHQ6IDcwMCwgdGV4dEFsaWduOiAnY2VudGVyJywgYm9yZGVyOiAnMXB4IHNvbGlkJywKICAgIGJhY2tncm91bmQ6IGFjdGl2ZSA/ICcjMWExYzJlJyA6ICdub25lJywKICAgIGJvcmRlckNvbG9yOiBhY3RpdmUgPyAnIzY2N2VlYScgOiAnIzJhMmIzYScsCiAgICBjb2xvcjogYWN0aXZlID8gJyM2NjdlZWEnIDogJyM3NzcnLAogIH0pLAogIGxhYmVsOiB7IGZvbnRTaXplOiAnMTFweCcsIGNvbG9yOiAnIzljYTBiOCcsIHRleHRUcmFuc2Zvcm06ICd1cHBlcmNhc2UnLCBsZXR0ZXJTcGFjaW5nOiAnMC41cHgnLCBtYXJnaW5Cb3R0b206ICc1cHgnIH0sCiAgaW5wdXQ6IHsKICAgIHdpZHRoOiAnMTAwJScsIGJhY2tncm91bmQ6ICcjMGQwZTE0JywgYm9yZGVyOiAnMXB4IHNvbGlkICMyYTJiM2EnLCBib3JkZXJSYWRpdXM6ICc2cHgnLAogICAgY29sb3I6ICcjZjhmOWZhJywgcGFkZGluZzogJzlweCAxMnB4JywgZm9udFNpemU6ICcxM3B4JywgbWFyZ2luQm90dG9tOiAnMTRweCcsCiAgICBvdXRsaW5lOiAnbm9uZScsIGJveFNpemluZzogJ2JvcmRlci1ib3gnLAogIH0sCiAgc2VsZWN0OiB7CiAgICB3aWR0aDogJzEwMCUnLCBiYWNrZ3JvdW5kOiAnIzBkMGUxNCcsIGJvcmRlcjogJzFweCBzb2xpZCAjMmEyYjNhJywgYm9yZGVyUmFkaXVzOiAnNnB4JywKICAgIGNvbG9yOiAnI2Y4ZjlmYScsIHBhZGRpbmc6ICc5cHggMTJweCcsIGZvbnRTaXplOiAnMTNweCcsIG1hcmdpbkJvdHRvbTogJzE0cHgnLAogICAgb3V0bGluZTogJ25vbmUnLCBib3hTaXppbmc6ICdib3JkZXItYm94JywKICB9LAogIHJvdzogeyBkaXNwbGF5OiAnZmxleCcsIGdhcDogJzEwcHgnLCBqdXN0aWZ5Q29udGVudDogJ2ZsZXgtZW5kJywgbWFyZ2luVG9wOiAnNnB4JyB9LAogIGJ0bjogKGNvbG9yLCBiZykgPT4gKHsKICAgIHBhZGRpbmc6ICc4cHggMThweCcsIGJvcmRlclJhZGl1czogJzZweCcsIGZvbnRXZWlnaHQ6IDcwMCwgZm9udFNpemU6ICcxM3B4JywKICAgIGN1cnNvcjogJ3BvaW50ZXInLCBib3JkZXI6IGAxcHggc29saWQgJHtjb2xvcn1gLCBjb2xvciwgYmFja2dyb3VuZDogYmcgfHwgJ25vbmUnLAogIH0pLAogIGVycm9yOiB7CiAgICBiYWNrZ3JvdW5kOiAnIzNjMTExNCcsIGJvcmRlcjogJzFweCBzb2xpZCAjZjVjNmNiJywgYm9yZGVyUmFkaXVzOiAnNnB4JywKICAgIHBhZGRpbmc6ICcxMHB4IDEycHgnLCBmb250U2l6ZTogJzEycHgnLCBjb2xvcjogJyNmZmIzYjgnLCBtYXJnaW5Cb3R0b206ICcxNHB4JywKICB9LAogIHN1Y2Nlc3M6IHsKICAgIGJhY2tncm91bmQ6ICcjMGEyZTFmJywgYm9yZGVyOiAnMXB4IHNvbGlkICMwZjUxMzInLCBib3JkZXJSYWRpdXM6ICc2cHgnLAogICAgcGFkZGluZzogJzEwcHggMTJweCcsIGZvbnRTaXplOiAnMTJweCcsIGNvbG9yOiAnIzllZjdjMScsIG1hcmdpbkJvdHRvbTogJzE0cHgnLAogIH0sCiAgbWVyZ2VXYXJuaW5nOiB7CiAgICBiYWNrZ3JvdW5kOiAnIzJhMWYwMCcsIGJvcmRlcjogJzFweCBzb2xpZCAjN2E1MDAwJywgYm9yZGVyUmFkaXVzOiAnNnB4JywKICAgIHBhZGRpbmc6ICcxMnB4JywgZm9udFNpemU6ICcxMnB4JywgY29sb3I6ICcjZmZjMTA3JywgbWFyZ2luQm90dG9tOiAnMTRweCcsIGxpbmVIZWlnaHQ6IDEuNSwKICB9LAp9OwoKZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gRWRpdEVtcGxveWVlTW9kYWwoeyBlbXBsb3llZSwgb25DbG9zZSwgb25TYXZlZCB9KSB7CiAgY29uc3QgW3RhYiwgc2V0VGFiXSA9IHVzZVN0YXRlKCdlZGl0Jyk7CgogIC8vIEVkaXQgc3RhdGUKICBjb25zdCBbbmFtZSwgc2V0TmFtZV0gICAgICAgICAgICAgICAgICAgPSB1c2VTdGF0ZShlbXBsb3llZS5uYW1lKTsKICBjb25zdCBbZGVwYXJ0bWVudCwgc2V0RGVwYXJ0bWVudF0gICAgICAgPSB1c2VTdGF0ZShlbXBsb3llZS5kZXBhcnRtZW50IHx8ICcnKTsKICBjb25zdCBbc3VwZXJ2aXNvciwgc2V0U3VwZXJ2aXNvcl0gICAgICAgPSB1c2VTdGF0ZShlbXBsb3llZS5zdXBlcnZpc29yIHx8ICcnKTsKICBjb25zdCBbZWRpdEVycm9yLCBzZXRFZGl0RXJyb3JdICAgICAgICAgPSB1c2VTdGF0ZSgnJyk7CiAgY29uc3QgW2VkaXRTYXZpbmcsIHNldEVkaXRTYXZpbmddICAgICAgID0gdXNlU3RhdGUoZmFsc2UpOwoKICAvLyBNZXJnZSBzdGF0ZQogIGNvbnN0IFthbGxFbXBsb3llZXMsIHNldEFsbEVtcGxveWVlc10gICA9IHVzZVN0YXRlKFtdKTsKICBjb25zdCBbc291cmNlSWQsIHNldFNvdXJjZUlkXSAgICAgICAgICAgPSB1c2VTdGF0ZSgnJyk7CiAgY29uc3QgW21lcmdlRXJyb3IsIHNldE1lcmdlRXJyb3JdICAgICAgID0gdXNlU3RhdGUoJycpOwogIGNvbnN0IFttZXJnZVJlc3VsdCwgc2V0TWVyZ2VSZXN1bHRdICAgICA9IHVzZVN0YXRlKG51bGwpOwogIGNvbnN0IFttZXJnaW5nLCBzZXRNZXJnaW5nXSAgICAgICAgICAgICA9IHVzZVN0YXRlKGZhbHNlKTsKCiAgdXNlRWZmZWN0KCgpID0+IHsKICAgIGlmICh0YWIgPT09ICdtZXJnZScpIHsKICAgICAgYXhpb3MuZ2V0KCcvYXBpL2VtcGxveWVlcycpLnRoZW4ociA9PiBzZXRBbGxFbXBsb3llZXMoci5kYXRhKSk7CiAgICB9CiAgfSwgW3RhYl0pOwoKICBjb25zdCBoYW5kbGVFZGl0ID0gYXN5bmMgKCkgPT4gewogICAgc2V0RWRLine truncated
import axios from 'axios';
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
},
modal: {
background: '#111217', color: '#f8f9fa', width: '480px', maxWidth: '95vw',
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
border: '1px solid #222', overflow: 'hidden',
},
header: {
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #222',
},
title: { fontSize: '15px', fontWeight: 700 },
closeBtn: {
background: 'none', border: 'none', color: 'white', fontSize: '20px',
cursor: 'pointer', lineHeight: 1,
},
body: { padding: '22px' },
tabs: { display: 'flex', gap: '4px', marginBottom: '20px' },
tab: (active) => ({
flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer', fontSize: '12px',
fontWeight: 700, textAlign: 'center', border: '1px solid',
background: active ? '#1a1c2e' : 'none',
borderColor: active ? '#667eea' : '#2a2b3a',
color: active ? '#667eea' : '#777',
}),
label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' },
input: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box',
},
select: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box',
},
row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' },
btn: (color, bg) => ({
padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px',
cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none',
}),
error: {
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px',
},
success: {
background: '#0a2e1f', border: '1px solid #0f5132', borderRadius: '6px',
padding: '10px 12px', fontSize: '12px', color: '#9ef7c1', marginBottom: '14px',
},
mergeWarning: {
background: '#2a1f00', border: '1px solid #7a5000', borderRadius: '6px',
padding: '12px', fontSize: '12px', color: '#ffc107', marginBottom: '14px', lineHeight: 1.5,
},
};
export default function EditEmployeeModal({ employee, onClose, onSaved }) {
const [tab, setTab] = useState('edit');
// Edit state
const [name, setName] = useState(employee.name);
const [department, setDepartment] = useState(employee.department || '');
const [supervisor, setSupervisor] = useState(employee.supervisor || '');
const [editError, setEditError] = useState('');
const [editSaving, setEditSaving] = useState(false);
// Merge state
const [allEmployees, setAllEmployees] = useState([]);
const [sourceId, setSourceId] = useState('');
const [mergeError, setMergeError] = useState('');
const [mergeResult, setMergeResult] = useState(null);
const [merging, setMerging] = useState(false);
useEffect(() => {
if (tab === 'merge') {
axios.get('/api/employees').then(r => setAllEmployees(r.data));
}
}, [tab]);
const handleEdit = async () => {
setEditError('');
setEditSaving(true);
try {
await axios.patch(`/api/employees/${employee.id}`, { name, department, supervisor });
onSaved();
onClose();
} catch (e) {
setEditError(e.response?.data?.error || 'Failed to save changes');
} finally {
setEditSaving(false);
}
};
const handleMerge = async () => {
if (!sourceId) return setMergeError('Select an employee to merge in');
setMergeError('');
setMerging(true);
try {
const r = await axios.post(`/api/employees/${employee.id}/merge`, { source_id: parseInt(sourceId) });
setMergeResult(r.data);
onSaved(); // refresh dashboard / parent list
} catch (e) {
setMergeError(e.response?.data?.error || 'Merge failed');
} finally {
setMerging(false);
}
};
const otherEmployees = allEmployees.filter(e => e.id !== employee.id);
return (
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={s.modal}>
<div style={s.header}>
<div style={s.title}>Edit Employee</div>
<button style={s.closeBtn} onClick={onClose}>✕</button>
</div>
<div style={s.body}>
<div style={s.tabs}>
<button style={s.tab(tab === 'edit')} onClick={() => setTab('edit')}>Edit Details</button>
<button style={s.tab(tab === 'merge')} onClick={() => setTab('merge')}>Merge Duplicate</button>
</div>
{tab === 'edit' && (
<>
{editError && <div style={s.error}>{editError}</div>}
<div style={s.label}>Full Name</div>
<input style={s.input} value={name} onChange={e => setName(e.target.value)} />
<div style={s.label}>Department</div>
<input style={s.input} value={department} onChange={e => setDepartment(e.target.value)} placeholder="Optional" />
<div style={s.label}>Supervisor</div>
<input style={s.input} value={supervisor} onChange={e => setSupervisor(e.target.value)} placeholder="Optional" />
<div style={s.row}>
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
<button style={s.btn('#fff', '#667eea')} onClick={handleEdit} disabled={editSaving}>
{editSaving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</>
)}
{tab === 'merge' && (
<>
{mergeResult ? (
<div style={s.success}>
✓ Merge complete — {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned
to <strong>{employee.name}</strong>. The duplicate record has been removed.
</div>
) : (
<>
<div style={s.mergeWarning}>
⚠ This will reassign <strong>all violations</strong> from the selected employee into{' '}
<strong>{employee.name}</strong>, then permanently delete the duplicate record.
This cannot be undone.
</div>
{mergeError && <div style={s.error}>{mergeError}</div>}
<div style={s.label}>Duplicate to merge into {employee.name}</div>
<select style={s.select} value={sourceId} onChange={e => setSourceId(e.target.value)}>
<option value="">— select employee —</option>
{otherEmployees.map(e => (
<option key={e.id} value={e.id}>{e.name}{e.department ? ` (${e.department})` : ''}</option>
))}
</select>
<div style={s.row}>
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
<button style={s.btn('#fff', '#c0392b')} onClick={handleMerge} disabled={merging || !sourceId}>
{merging ? 'Merging…' : 'Merge & Delete Duplicate'}
</button>
</div>
</>
)}
{mergeResult && (
<div style={s.row}>
<button style={s.btn('#fff', '#667eea')} onClick={onClose}>Done</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
+1 -343
View File
@@ -1,343 +1 @@
import React, { useState, useEffect } from 'react'; aW1wb3J0IFJlYWN0LCB7IHVzZVN0YXRlLCB1c2VFZmZlY3QgfSBmcm9tICdyZWFjdCc7CmltcG9ydCBheGlvcyBmcm9tICdheGlvcyc7CmltcG9ydCB7IHZpb2xhdGlvbkRhdGEsIHZpb2xhdGlvbkdyb3VwcyB9IGZyb20gJy4uL2RhdGEvdmlvbGF0aW9ucyc7CmltcG9ydCB1c2VFbXBsb3llZUludGVsbGlnZW5jZSBmcm9tICcuLi9ob29rcy91c2VFbXBsb3llZUludGVsbGlnZW5jZSc7CmltcG9ydCBDcGFzQmFkZ2UgZnJvbSAnLi9DcGFzQmFkZ2UnOwppbXBvcnQgVGllcldhcm5pbmcgZnJvbSAnLi9UaWVyV2FybmluZyc7CmltcG9ydCBWaW9sYXRpb25IaXN0b3J5IGZyb20gJy4vVmlvbGF0aW9uSGlzdG9yeSc7CmltcG9ydCB7IHVzZVRvYXN0IH0gZnJvbSAnLi9Ub2FzdFByb3ZpZGVyJzsKaW1wb3J0IHsgREVQQVJUTUVOVFMgfSBmcm9tICcuLi9kYXRhL2RlcGFydG1lbnRzJzsKCmNvbnN0IHMgPSB7CiAgY29udGVudDogICAgICB7IHBhZGRpbmc6ICczMnB4IDQwcHgnLCBiYWNrZ3JvdW5kOiAnIzExMTIxNycsIGJvcmRlclJhZGl1czogJzEwcHgnLCBjb2xvcjogJyNmOGY5ZmEnIH0sCiAgc2VjdGlvbjogICAgICB7IGJhY2tncm91bmQ6ICcjMTgxOTI0JywgYm9yZGVyTGVmdDogJzRweCBzb2xpZCAjZDRhZjM3JywgcGFkZGluZzogJzIwcHgnLCBtYXJnaW5Cb3R0b206ICczMHB4JywgYm9yZGVyUmFkaXVzOiAnNHB4JywgYm9yZGVyOiAnMXB4IHNvbGlkICMyYTJiM2EnIH0sCiAgc2VjdGlvblRpdGxlOiB7IGNvbG9yOiAnI2Y4ZjlmYScsIGZvbnRTaXplOiAnMjBweCcsIG1hcmdpbkJvdHRvbTogJzE1cHgnLCBmb250V2VpZ2h0OiA3MDAgfSwKICBncmlkOiAgICAgICAgIHsgZGlzcGxheTogJ2dyaWQnLCBncmlkVGVtcGxhdGVDb2x1bW5zOiAncmVwZWF0KGF1dG8tZml0LCBtaW5tYXgoMjUwcHgsIDFmcikpJywgZ2FwOiAnMTVweCcsIG1hcmdpblRvcDogJzE1cHgnIH0sCiAgaXRlbTogICAgICAgICB7IGRpc3BsYXk6ICdmbGV4JywgZmxleERpcmVjdGlvbjogJ2NvbHVtbid9LAogIGxhYmVsOiAgICAgICAgeyBmb250V2VpZ2h0OiA2MDAsIGNvbG9yOiAnI2U1ZTdmMScsIG1hcmdpbkJvdHRvbTogJzVweCcsIGZvbnRTaXplOiAnMTNweCcgfSwKICBpbnB1dDogICAgICAgIHsgcGFkZGluZzogJzEwcHgnLCBib3JkZXI6ICcxcHggc29saWQgIzMzMzU0NCcsIGJvcmRlclJhZGl1czogJzRweCcsIGZvbnRTaXplOiAnMTRweCcsIGZvbnRGYW1pbHk6ICdpbmhlcml0JywgYmFja2dyb3VuZDogJyMwNTA2MDgnLCBjb2xvcjogJyNmOGY5ZmEnIH0sCiAgZnVsbENvbDogICAgICB7IGdyaWRDb2x1bW46ICcxIC8gLTEnIH0sCiAgY29udGV4dEJveDogICB7IGJhY2tncm91bmQ6ICcjMTQxNjIzJywgYm9yZGVyOiAnMXB4IHNvbGlkICMzMzM1NDQnLCBib3JkZXJSYWRpdXM6ICc0cHgnLCBwYWRkaW5nOiAnMTBweCcsIGZvbnRTaXplOiAnMTJweCcsIGNvbG9yOiAnI2QxZDNlMCcsIG1hcmdpblRvcDogJzRweCcgfSwKICByZXBlYXRCYWRnZTogIHsgZGlzcGxheTogJ2lubGluZS1ibG9jaycsIG1hcmdpbkxlZnQ6ICc4cHgnLCBwYWRkaW5nOiAnMXB4IDdweCcsIGJvcmRlclJhZGl1czogJzEwcHgnLCBmb250U2l6ZTogJzExcHgnLCBmb250V2VpZ2h0OiA3MDAsIGJhY2tncm91bmQ6ICcjM2IyZTAwJywgY29sb3I6ICcjZmZkNjY2JywgYm9yZGVyOiAnMXB4IHNvbGlkICNkNGFmMzcnIH0sCiAgcmVwZWF0V2FybjogICB7IGJhY2tncm91bmQ6ICcjM2IyZTAwJywgYm9yZGVyOiAnMXB4IHNvbGlkICNkNGFmMzcnLCBib3JkZXJSYWRpdXM6ICc0cHgnLCBwYWRkaW5nOiAnOHB4IDEycHgnLCBtYXJnaW5Ub3A6ICc2cHgnLCBmb250U2l6ZTogJzEycHgnLCBjb2xvcjogJyNmZmRmOGEnIH0sCiAgcG9pbnRCb3g6ICAgICB7IGJhY2tncm91bmQ6ICcjMTgxMjAwJywgYm9yZGVyOiAnMnB4IHNvbGlkICNkNGFmMzcnLCBwYWRkaW5nOiAnMTVweCcsIGJvcmRlclJhZGl1czogJzZweCcsIG1hcmdpblRvcDogJzE1cHgnLCB0ZXh0QWxpZ246ICdjZW50ZXInIH0sCiAgcG9pbnRWYWx1ZTogICB7IGZvbnRTaXplOiAnMjRweCcsIGZvbnRXZWlnaHQ6ICdib2xkJywgY29sb3I6ICcjZmZkNjY2JywgbWFyZ2luOiAnMTBweCAwJyB9LAogIHNjb3JlUm93OiAgICAgeyBkaXNwbGF5OiAnZmxleCcsIGFsaWduSXRlbXM6ICdjZW50ZXInLCBnYXA6ICcxMnB4JywgbWFyZ2luQm90dG9tOiAnMTRweCcsIGZsZXhXcmFwOiAnd3JhcCcgfSwKICBidG5Sb3c6ICAgICAgIHsgZGlzcGxheTogJ2ZsZXgnLCBnYXA6ICcxNXB4JywganVzdGlmeUNvbnRlbnQ6ICdjZW50ZXInLCBtYXJnaW5Ub3A6ICczMHB4JywgZmxleFdyYXA6ICd3cmFwJyB9LAogIGJ0blByaW1hcnk6ICAgeyBwYWRkaW5nOiAnMTVweCA0MHB4JywgZm9udFNpemU6ICcxNnB4JywgZm9udFdlaWdodDogNjAwLCBib3JkZXI6ICdub25lJywgYm9yZGVyUmFkaXVzOiAnNnB4JywgY3Vyc29yOiAncG9pbnRlcicsIGJhY2tncm91bmQ6ICdsaW5lYXItZ3JhZGllbnQoMTM1ZGVnLCAjZDRhZjM3IDAlLCAjZmZkZjhhIDEwMCUpJywgY29sb3I6ICcjMDAwJywgdGV4dFRyYW5zZm9ybTogJ3VwcGVyY2FzZScgfSwKICBidG5QZGY6ICAgICAgIHsgcGFkZGluZzogJzE1cHggNDBweCcsIGZvbnRTaXplOiAnMTZweCcsIGZvbnRXZWlnaHQ6IDYwMCwgYm9yZGVyOiAnbm9uZScsIGJvcmRlclJhZGl1czogJzZweCcsIGN1cnNvcjogJ3BvaW50ZXInLCBiYWNrZ3JvdW5kOiAnbGluZWFyLWdyYWRpZW50KDEzNWRlZywgI2U3NGMzYyAwJSwgI2MwMzkyYiAxMDAlKScsIGNvbG9yOiAnd2hpdGUnLCB0ZXh0VHJhbnNmb3JtOiAndXBwZXJjYXNlJyB9LAogIGJ0blNlY29uZGFyeTogeyBwYWRkaW5nOiAnMTVweCA0MHB4JywgZm9udFNpemU6ICcxNnB4JywgZm9udFdlaWdodDogNjAwLCBib3JkZXI6ICcxcHggc29saWQgIzMzMzU0NCcsIGJvcmRlclJhZGl1czogJzZweCcsIGN1cnNvcjogJ3BvaW50ZXInLCBiYWNrZ3JvdW5kOiAnIzA1MDYwOCcsIGNvbG9yOiAnI2Y4ZjlmYScsIHRleHRUcmFuc2Zvcm06ICd1cHBlcmNhc2UnIH0sCiAgbm90ZTogICAgICAgICB7IGJhY2tncm91bmQ6ICcjMTQxNjIzJywgYm9yZGVyTGVmdDogJzRweCBzb2xpZCAjMjE5NkYzJywgcGFkZGluZzogJzE1cHgnLCBtYXJnaW46ICcyMHB4IDAnLCBib3JkZXJSYWRpdXM6ICc0cHgnLCBmb250U2l6ZTogJzEzcHgnLCBjb2xvcjogJyNkMWQzZTAnIH0sCiAgYWNrU2VjdGlvbjogICB7IGJhY2tncm91bmQ6ICcjMTgxOTI0JywgYm9yZGVyTGVmdDogJzRweCBzb2xpZCAjMjE5NkYzJywgcGFkZGluZzogJzIwcHgnLCBtYXJnaW5Cb3R0b206ICczMHB4JywgYm9yZGVyUmFkaXVzOiAnNHB4JywgYm9yZGVyOiAnMXB4IHNvbGlkICMyYTJiM2EnIH0sCiAgYWNrSGludDogICAgICB7IGZvbnRTaXplOiAnMTJweCcsIGNvbG9yOiAnIzljYTBiOCcsIG1hcmdpblRvcDogJzRweCcsIGZvbnRTdHlsZTogJ2l0YWxpYycgfSwKfTsKCmNvbnN0IEVNUFRZX0ZPUk0gPSB7CiAgZW1wbG95ZWVJZDogJycsIGVtcGxveWVlTmFtZTogJycsIGRlcGFydG1lbnQ6ICcnLCBzdXBlcnZpc29yOiAnJywgd2l0bmVzc05hbWU6ICcnLAogIHZpb2xhdGlvblR5cGU6ICcnLCBpbmNpZGVudERhdGU6ICcnLCBpbmNpZGVudFRpbWU6ICcnLAogIGFtb3VudDogJycsIG1pbnV0ZXNMYXRlOiAnJywgbG9jYXRpb246ICcnLCBhZGRpdGlvbmFsRGV0YWlsczogJycsIHBvaW50czogMSwKICBhY2tub3dsZWRnZWRCeTogJycsIGFja25vd2xlZGdlZERhdGU6ICcnLAp9OwoLine truncated
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';
import { useToast } from './ToastProvider';
const s = {
content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' },
section: { background: '#181924', borderLeft: '4px solid #d4af37', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
sectionTitle: { color: '#f8f9fa', fontSize: '20px', marginBottom: '15px', fontWeight: 700 },
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
item: { display: 'flex', flexDirection: 'column' },
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px' },
input: { padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa' },
fullCol: { gridColumn: '1 / -1' },
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: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37' },
repeatWarn: { background: '#3b2e00', border: '1px solid #d4af37', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#ffdf8a' },
pointBox: { background: '#181200', border: '2px solid #d4af37', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#ffd666', 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, #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' },
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' },
note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' },
ackSection: { background: '#181924', borderLeft: '4px solid #2196F3', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
ackHint: { fontSize: '12px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
};
const EMPTY_FORM = {
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
violationType: '', incidentDate: '', incidentTime: '',
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
acknowledgedBy: '', acknowledgedDate: '',
};
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 [lastViolId, setLastViolId] = useState(null);
const [pdfLoading, setPdfLoading] = useState(false);
const toast = useToast();
const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
}, []);
useEffect(() => {
if (!violation || !form.violationType) return;
const allTime = intel.countsAllTime[form.violationType];
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
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;
setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' }));
};
const handleViolationChange = e => {
const key = e.target.value;
const v = violationData[key] || null;
setViolation(v);
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
};
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
const handleSubmit = async e => {
e.preventDefault();
if (!form.violationType) { toast.warning('Please select a violation type.'); return; }
if (!form.employeeName) { toast.warning('Please enter an employee name.'); return; }
try {
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
const employeeId = empRes.data.id;
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,
acknowledged_by: form.acknowledgedBy || null,
acknowledged_date: form.acknowledgedDate || null,
});
const newId = violRes.data.id;
setLastViolId(newId);
const empList = await axios.get('/api/employees');
setEmployees(empList.data);
toast.success(`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);
setViolation(null);
} catch (err) {
const msg = err.response?.data?.error || err.message;
toast.error(`Failed to submit: ${msg}`);
setStatus({ ok: false, msg: '✗ Error: ' + msg });
}
};
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);
toast.success('PDF downloaded successfully.');
} catch (err) {
toast.error('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;
return (
<div style={s.content}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Employee Information</h2>
{intel.score && form.employeeId && (
<div style={s.scoreRow}>
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
<CpasBadge points={intel.score.active_points} />
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
{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>
<select style={s.input} onChange={handleEmployeeSelect} value={form.employeeId || ''}>
<option value="">-- Select existing or enter new below --</option>
{employees.map(e => (
<option key={e.id} value={e.id}>{e.name}{e.department ? ` — ${e.department}` : ''}</option>
))}
</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}>
<label style={s.label}>{label}:</label>
<input style={s.input} type={type} name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
</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>
)}
{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: '#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>
{/* Acknowledgment Signature Section */}
<div style={s.ackSection}>
<h2 style={{ ...s.sectionTitle, fontSize: '17px' }}>Employee Acknowledgment</h2>
<p style={{ fontSize: '12px', color: '#9ca0b8', marginBottom: '14px', lineHeight: 1.6 }}>
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
This replaces the blank signature line on the PDF with a recorded acknowledgment.
</p>
<div style={s.grid}>
<div style={s.item}>
<label style={s.label}>Acknowledged By (Employee Name):</label>
<input style={s.input} type="text" name="acknowledgedBy" value={form.acknowledgedBy} onChange={handleChange} placeholder="Employee's printed name" />
<div style={s.ackHint}>Leave blank if employee is not present or declines to sign</div>
</div>
<div style={s.item}>
<label style={s.label}>Acknowledgment Date:</label>
<input style={s.input} type="date" name="acknowledgedDate" value={form.acknowledgedDate} onChange={handleChange} />
<div style={s.ackHint}>Date the employee received and acknowledged this document</div>
</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 ? { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' } : { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }}>{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>
);
}
+10
View File
@@ -0,0 +1,10 @@
// Canonical department list – shared across all forms
export const DEPARTMENTS = [
'Administrative',
'Business Development',
'Design and Content',
'Executive',
'Implementation and Support',
'Operations',
'Production',
];