p4-hotfixes #16
@@ -4,24 +4,150 @@ import CpasBadge, { getTier } from './CpasBadge';
|
||||
import NegateModal from './NegateModal';
|
||||
|
||||
const s = {
|
||||
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end' },
|
||||
panel: { background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw', height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)', display: 'flex', flexDirection: 'column' },
|
||||
header: { background: 'linear-gradient(135deg, #000000, #151622)', color: 'white', padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10, borderBottom: '1px solid #222' },
|
||||
closeBtn: { float: 'right', background: 'none', border: 'none', color: 'white', fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px' },
|
||||
body: { padding: '24px 28px', flex: 1 },
|
||||
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
|
||||
scoreCard: { flex: '1', minWidth: '100px', background: '#181924', borderRadius: '8px', padding: '14px', textAlign: 'center', border: '1px solid #2a2b3a' },
|
||||
scoreNum: { fontSize: '26px', fontWeight: 800 },
|
||||
scoreLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '3px' },
|
||||
sectionHd: { fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '10px', marginTop: '24px' },
|
||||
table: { width: '100%', borderCollapse: 'collapse', fontSize: '12px', background: '#181924', borderRadius: '6px', overflow: 'hidden', border: '1px solid #2a2b3a' },
|
||||
th: { background: '#050608', padding: '8px 10px', textAlign: 'left', color: '#f8f9fa', fontWeight: 600, fontSize: '11px', textTransform: 'uppercase' },
|
||||
td: { padding: '9px 10px', borderBottom: '1px solid #202231', verticalAlign: 'top', color: '#f8f9fa' },
|
||||
negatedRow: { background: '#151622', color: '#9ca0b8' },
|
||||
actionBtn: (color) => ({ background: 'none', border: `1px solid ${color}`, color, borderRadius: '4px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', marginRight: '4px', fontWeight: 600 }),
|
||||
resTag: { display: 'inline-block', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' },
|
||||
pdfBtn: { background: 'none', border: '1px solid #d4af37', color: '#ffd666', borderRadius: '4px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', fontWeight: 600 },
|
||||
deleteConfirm: { background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px', padding: '12px', marginTop: '8px', fontSize: '12px', color: '#ffb3b8' },
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
panel: {
|
||||
background: '#111217',
|
||||
color: '#f8f9fa',
|
||||
width: '680px',
|
||||
maxWidth: '95vw',
|
||||
height: '100vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
background: 'linear-gradient(135deg, #000000, #151622)',
|
||||
color: 'white',
|
||||
padding: '24px 28px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
borderBottom: '1px solid #222',
|
||||
},
|
||||
closeBtn: {
|
||||
float: 'right',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: '22px',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
marginTop: '-2px',
|
||||
},
|
||||
body: {
|
||||
padding: '24px 28px',
|
||||
flex: 1,
|
||||
},
|
||||
scoreRow: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '24px',
|
||||
},
|
||||
scoreCard: {
|
||||
flex: '1',
|
||||
minWidth: '100px',
|
||||
background: '#181924',
|
||||
borderRadius: '8px',
|
||||
padding: '14px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #2a2b3a',
|
||||
},
|
||||
scoreNum: {
|
||||
fontSize: '26px',
|
||||
fontWeight: 800,
|
||||
},
|
||||
scoreLbl: {
|
||||
fontSize: '11px',
|
||||
color: '#b5b5c0',
|
||||
marginTop: '3px',
|
||||
},
|
||||
sectionHd: {
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
color: '#f8f9fa',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '10px',
|
||||
marginTop: '24px',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '12px',
|
||||
background: '#181924',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #2a2b3a',
|
||||
},
|
||||
th: {
|
||||
background: '#050608',
|
||||
padding: '8px 10px',
|
||||
textAlign: 'left',
|
||||
color: '#f8f9fa',
|
||||
fontWeight: 600,
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
td: {
|
||||
padding: '9px 10px',
|
||||
borderBottom: '1px solid #202231',
|
||||
verticalAlign: 'top',
|
||||
color: '#f8f9fa',
|
||||
},
|
||||
negatedRow: {
|
||||
background: '#151622',
|
||||
color: '#9ca0b8',
|
||||
},
|
||||
actionBtn: (color) => ({
|
||||
background: 'none',
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
borderRadius: '4px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '4px',
|
||||
fontWeight: 600,
|
||||
}),
|
||||
resTag: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
background: '#053321',
|
||||
color: '#9ef7c1',
|
||||
border: '1px solid #0f5132',
|
||||
},
|
||||
pdfBtn: {
|
||||
background: 'none',
|
||||
border: '1px solid #d4af37',
|
||||
color: '#ffd666',
|
||||
borderRadius: '4px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
},
|
||||
deleteConfirm: {
|
||||
background: '#3c1114',
|
||||
border: '1px solid #f5c6cb',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#ffb3b8',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EmployeeModal({ employeeId, onClose }) {
|
||||
@@ -38,19 +164,27 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
||||
axios.get('/api/employees'),
|
||||
axios.get(`/api/employees/${employeeId}/score`),
|
||||
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
|
||||
]).then(([empRes, scoreRes, violRes]) => {
|
||||
const emp = empRes.data.find(e => e.id === employeeId);
|
||||
])
|
||||
.then(([empRes, scoreRes, violRes]) => {
|
||||
const emp = empRes.data.find((e) => e.id === employeeId);
|
||||
setEmployee(emp || null);
|
||||
setScore(scoreRes.data);
|
||||
setViolations(violRes.data);
|
||||
}).finally(() => setLoading(false));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [employeeId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDownloadPdf = async (violId, empName, date) => {
|
||||
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||
const response = await axios.get(`/api/violations/${violId}/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_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
|
||||
@@ -84,12 +218,341 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
||||
};
|
||||
|
||||
const tier = score ? getTier(score.active_points) : null;
|
||||
const active = violations.filter(v => !v.negated);
|
||||
const negated = violations.filter(v => v.negated);
|
||||
const active = violations.filter((v) => !v.negated);
|
||||
const negated = violations.filter((v) => v.negated);
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{/* panel and tables unchanged; omitted here for brevity */}
|
||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||
<div style={s.panel}>
|
||||
<div style={s.header}>
|
||||
<button style={s.closeBtn} onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700 }}>
|
||||
{loading ? 'Loading…' : employee?.name || 'Employee Profile'}
|
||||
</div>
|
||||
{employee && (
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '4px' }}>
|
||||
{[employee.department, employee.supervisor ? `Supervisor: ${employee.supervisor}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
{loading ? (
|
||||
<p
|
||||
style={{
|
||||
color: '#77798a',
|
||||
textAlign: 'center',
|
||||
paddingTop: '40px',
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={s.scoreRow}>
|
||||
<div
|
||||
style={{
|
||||
...s.scoreCard,
|
||||
borderTop: `3px solid ${tier?.color}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...s.scoreNum,
|
||||
color: tier?.color,
|
||||
}}
|
||||
>
|
||||
{score?.active_points ?? 0}
|
||||
</div>
|
||||
<div style={s.scoreLbl}>Active Points</div>
|
||||
</div>
|
||||
<div style={s.scoreCard}>
|
||||
<div style={s.scoreNum}>{score?.violation_count ?? 0}</div>
|
||||
<div style={s.scoreLbl}>90-Day Violations</div>
|
||||
</div>
|
||||
<div style={s.scoreCard}>
|
||||
<div style={s.scoreNum}>{active.length}</div>
|
||||
<div style={s.scoreLbl}>Total On Record</div>
|
||||
</div>
|
||||
<div style={s.scoreCard}>
|
||||
<div
|
||||
style={{
|
||||
...s.scoreNum,
|
||||
color: '#ffd666',
|
||||
}}
|
||||
>
|
||||
{negated.length}
|
||||
</div>
|
||||
<div style={s.scoreLbl}>Negated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tier && (
|
||||
<div
|
||||
style={{
|
||||
background: '#181924',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 14px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '13px',
|
||||
border: `1px solid ${tier.color}33`,
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: tier.color }}>{tier.label}</strong>
|
||||
<span
|
||||
style={{
|
||||
color: '#b5b5c0',
|
||||
marginLeft: '10px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Rolling 90-day window · Points expire automatically
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={s.sectionHd}>Active Violations</div>
|
||||
{active.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
color: '#77798a',
|
||||
fontSize: '13px',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No active violations on record.
|
||||
</p>
|
||||
) : (
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Date</th>
|
||||
<th style={s.th}>Violation</th>
|
||||
<th style={s.th}>Pts</th>
|
||||
<th style={s.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td style={s.td}>{v.incident_date}</td>
|
||||
<td style={s.td}>
|
||||
<div style={{ fontWeight: 600 }}>{v.violation_name}</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#b5b5c0',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{v.category}
|
||||
</div>
|
||||
{v.details && (
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d3e0',
|
||||
fontSize: '11px',
|
||||
marginTop: '3px',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{v.details}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...s.td,
|
||||
fontWeight: 700,
|
||||
color: '#ff8a80',
|
||||
}}
|
||||
>
|
||||
{v.points}
|
||||
</td>
|
||||
<td style={s.td}>
|
||||
<button
|
||||
style={s.actionBtn('#ffd666')}
|
||||
onClick={() => setNegating(v)}
|
||||
>
|
||||
⊘ Negate
|
||||
</button>
|
||||
<button
|
||||
style={s.pdfBtn}
|
||||
onClick={() =>
|
||||
handleDownloadPdf(
|
||||
v.id,
|
||||
employee?.name,
|
||||
v.incident_date,
|
||||
)
|
||||
}
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
<br />
|
||||
{confirmDel === v.id ? (
|
||||
<div style={s.deleteConfirm}>
|
||||
<strong>Permanently delete?</strong> This cannot be
|
||||
undone.
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={s.actionBtn('#ffb3b8')}
|
||||
onClick={() => handleHardDelete(v.id)}
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#9ca0b8')}
|
||||
onClick={() => setConfirmDel(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={{
|
||||
...s.actionBtn('#c0392b'),
|
||||
marginTop: '4px',
|
||||
}}
|
||||
onClick={() => setConfirmDel(v.id)}
|
||||
>
|
||||
✕ Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{negated.length > 0 && (
|
||||
<>
|
||||
<div style={s.sectionHd}>Negated / Resolved Violations</div>
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Date</th>
|
||||
<th style={s.th}>Violation</th>
|
||||
<th style={s.th}>Pts</th>
|
||||
<th style={s.th}>Resolution</th>
|
||||
<th style={s.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{negated.map((v) => (
|
||||
<tr key={v.id} style={s.negatedRow}>
|
||||
<td style={s.td}>{v.incident_date}</td>
|
||||
<td style={s.td}>
|
||||
<div style={{ textDecoration: 'line-through' }}>
|
||||
{v.violation_name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#9ca0b8',
|
||||
}}
|
||||
>
|
||||
{v.category}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...s.td,
|
||||
textDecoration: 'line-through',
|
||||
color: '#9ca0b8',
|
||||
}}
|
||||
>
|
||||
{v.points}
|
||||
</td>
|
||||
<td style={s.td}>
|
||||
<span style={s.resTag}>{v.resolution_type}</span>
|
||||
{v.resolution_details && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
marginTop: '3px',
|
||||
color: '#d1d3e0',
|
||||
}}
|
||||
>
|
||||
{v.resolution_details}
|
||||
</div>
|
||||
)}
|
||||
{v.resolved_by && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#9ca0b8',
|
||||
}}
|
||||
>
|
||||
by {v.resolved_by}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={s.td}>
|
||||
<button
|
||||
style={s.actionBtn('#9ef7c1')}
|
||||
onClick={() => handleRestore(v.id)}
|
||||
>
|
||||
↩ Restore
|
||||
</button>
|
||||
{confirmDel === v.id ? (
|
||||
<div style={s.deleteConfirm}>
|
||||
<strong>Permanently delete?</strong>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={s.actionBtn('#ffb3b8')}
|
||||
onClick={() => handleHardDelete(v.id)}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
style={s.actionBtn('#9ca0b8')}
|
||||
onClick={() => setConfirmDel(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={s.actionBtn('#c0392b')}
|
||||
onClick={() => setConfirmDel(v.id)}
|
||||
>
|
||||
✕ Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{negating && (
|
||||
<NegateModal
|
||||
violation={negating}
|
||||
|
||||
@@ -123,8 +123,12 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget && onCancel) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel && onCancel(); }}>
|
||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||
<div style={s.modal}>
|
||||
<div style={s.header}>
|
||||
<div style={s.title}>⊘ Negate Violation Points</div>
|
||||
@@ -149,7 +153,6 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||
<option>Documentation Error</option>
|
||||
<option>Policy Clarification / Exception</option>
|
||||
<option>Management Discretion</option>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -175,10 +178,18 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||
</div>
|
||||
|
||||
<div style={s.footer}>
|
||||
<button type="button" style={s.btnCancel} onClick={() => onCancel && onCancel()}>
|
||||
<button
|
||||
type="button"
|
||||
style={s.btnCancel}
|
||||
onClick={() => onCancel && onCancel()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" style={s.btnConfirm} onClick={handleConfirm}>
|
||||
<button
|
||||
type="button"
|
||||
style={s.btnConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm Negation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user