363 lines
15 KiB
JavaScript
Executable File
363 lines
15 KiB
JavaScript
Executable File
import React, { useState, useEffect, useCallback } from 'react';
|
|
import axios from 'axios';
|
|
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',
|
|
},
|
|
};
|
|
|
|
export default function EmployeeModal({ employeeId, onClose }) {
|
|
const [employee, setEmployee] = useState(null);
|
|
const [score, setScore] = useState(null);
|
|
const [violations, setViolations] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [negating, setNegating] = useState(null);
|
|
const [confirmDel, setConfirmDel] = useState(null);
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true);
|
|
Promise.all([
|
|
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);
|
|
setEmployee(emp || null);
|
|
setScore(scoreRes.data);
|
|
setViolations(violRes.data);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [employeeId]);
|
|
|
|
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 link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleHardDelete = async (id) => {
|
|
await axios.delete(`/api/violations/${id}`);
|
|
setConfirmDel(null);
|
|
load();
|
|
};
|
|
|
|
const handleRestore = async (id) => {
|
|
await axios.patch(`/api/violations/${id}/restore`);
|
|
setConfirmDel(null);
|
|
load();
|
|
};
|
|
|
|
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
|
|
await axios.patch(`/api/violations/${negating.id}/negate`, {
|
|
resolution_type, details, resolved_by,
|
|
});
|
|
setNegating(null);
|
|
setConfirmDel(null);
|
|
load();
|
|
};
|
|
|
|
const tier = score ? getTier(score.active_points) : null;
|
|
const active = violations.filter((v) => !v.negated);
|
|
const negated = violations.filter((v) => v.negated);
|
|
|
|
// FIX: overlay click only closes if clicking the backdrop itself, NOT children
|
|
const handleOverlayClick = (e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
};
|
|
|
|
return (
|
|
// FIX: panel uses onClick stopPropagation to prevent bubbling to overlay
|
|
<div style={s.overlay} onClick={handleOverlayClick}>
|
|
<div style={s.panel} onClick={(e) => e.stopPropagation()}>
|
|
|
|
{/* ── Header ── */}
|
|
<div style={s.header}>
|
|
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
|
<div style={{ fontSize: '18px', fontWeight: 700 }}>
|
|
{employee ? employee.name : 'Employee'}
|
|
</div>
|
|
{employee && (
|
|
<div style={{ fontSize: '12px', color: '#b5b5c0', marginTop: '4px' }}>
|
|
{employee.department} {employee.supervisor && `· Supervisor: ${employee.supervisor}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Body ── */}
|
|
<div style={s.body}>
|
|
{loading ? (
|
|
<div style={{ padding: '40px', textAlign: 'center', color: '#b5b5c0' }}>Loading…</div>
|
|
) : (
|
|
<>
|
|
{/* Score Cards */}
|
|
{score && (
|
|
<div style={s.scoreRow}>
|
|
<div style={s.scoreCard}>
|
|
<div style={{ ...s.scoreNum, color: tier?.color || '#f8f9fa' }}>
|
|
{score.active_points}
|
|
</div>
|
|
<div style={s.scoreLbl}>Active Points</div>
|
|
</div>
|
|
<div style={s.scoreCard}>
|
|
<div style={s.scoreNum}>{score.total_violations}</div>
|
|
<div style={s.scoreLbl}>Total Violations</div>
|
|
</div>
|
|
<div style={s.scoreCard}>
|
|
<div style={s.scoreNum}>{score.negated_count}</div>
|
|
<div style={s.scoreLbl}>Negated</div>
|
|
</div>
|
|
<div style={{ ...s.scoreCard, minWidth: '140px' }}>
|
|
<div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}>
|
|
{tier ? tier.label : '—'}
|
|
</div>
|
|
<div style={s.scoreLbl}>Current Tier</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{score && <CpasBadge points={score.active_points} style={{ marginBottom: '20px' }} />}
|
|
|
|
{/* ── Active Violations ── */}
|
|
<div style={s.sectionHd}>Active Violations</div>
|
|
{active.length === 0 ? (
|
|
<div style={{ color: '#77798a', fontStyle: 'italic', fontSize: '12px' }}>
|
|
No active violations on record.
|
|
</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}>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={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
|
{v.details && (
|
|
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>
|
|
{v.details}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td style={{ ...s.td, fontWeight: 700 }}>{v.points}</td>
|
|
<td style={s.td}>
|
|
{/* FIX: All buttons use e.stopPropagation() to prevent overlay close */}
|
|
<button
|
|
style={s.actionBtn('#ffc107')}
|
|
onClick={(e) => { e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
|
|
>
|
|
Negate
|
|
</button>
|
|
<button
|
|
style={s.actionBtn('#ff4d4f')}
|
|
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
|
|
>
|
|
{confirmDel === v.id ? 'Cancel' : 'Delete'}
|
|
</button>
|
|
<button
|
|
style={s.pdfBtn}
|
|
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
|
|
>
|
|
PDF
|
|
</button>
|
|
{confirmDel === v.id && (
|
|
<div style={s.deleteConfirm}>
|
|
Permanently delete? This cannot be undone.
|
|
<div style={{ marginTop: '8px' }}>
|
|
<button
|
|
style={s.actionBtn('#ff4d4f')}
|
|
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
|
|
>
|
|
Confirm Delete
|
|
</button>
|
|
<button
|
|
style={s.actionBtn('#888')}
|
|
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* ── Negated / Resolved Violations ── */}
|
|
{negated.length > 0 && (
|
|
<>
|
|
<div style={s.sectionHd}>Negated / Resolved</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={{ fontWeight: 600 }}>{v.violation_name}</div>
|
|
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
|
</td>
|
|
<td style={s.td}>{v.points}</td>
|
|
<td style={s.td}>
|
|
<span style={s.resTag}>{v.resolution_type}</span>
|
|
{v.resolution_details && (
|
|
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>
|
|
{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('#4db6ac')}
|
|
onClick={(e) => { e.stopPropagation(); handleRestore(v.id); }}
|
|
>
|
|
Restore
|
|
</button>
|
|
<button
|
|
style={s.actionBtn('#ff4d4f')}
|
|
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
|
|
>
|
|
{confirmDel === v.id ? 'Cancel' : 'Delete'}
|
|
</button>
|
|
<button
|
|
style={s.pdfBtn}
|
|
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
|
|
>
|
|
PDF
|
|
</button>
|
|
{confirmDel === v.id && (
|
|
<div style={s.deleteConfirm}>
|
|
Permanently delete? This cannot be undone.
|
|
<div style={{ marginTop: '8px' }}>
|
|
<button
|
|
style={s.actionBtn('#ff4d4f')}
|
|
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
|
|
>
|
|
Confirm Delete
|
|
</button>
|
|
<button
|
|
style={s.actionBtn('#888')}
|
|
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* FIX: NegateModal rendered OUTSIDE the panel so it sits at root z-index:2000 */}
|
|
{negating && (
|
|
<NegateModal
|
|
violation={negating}
|
|
onConfirm={handleNegate}
|
|
onCancel={() => setNegating(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|