Compare commits

...

3 Commits

2 changed files with 498 additions and 160 deletions

View File

@@ -4,32 +4,158 @@ 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 }) {
const [employee, setEmployee] = useState(null);
const [score, setScore] = useState(null);
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 [loading, setLoading] = useState(true);
const [negating, setNegating] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const load = useCallback(() => {
@@ -38,22 +164,30 @@ 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);
setEmployee(emp || null);
setScore(scoreRes.data);
setViolations(violRes.data);
}).finally(() => setLoading(false));
])
.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]);
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`;
link.href = url;
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
@@ -83,148 +217,339 @@ export default function EmployeeModal({ employeeId, onClose }) {
load();
};
const tier = score ? getTier(score.active_points) : null;
const active = violations.filter(v => !v.negated);
const negated = violations.filter(v => v.negated);
const tier = score ? getTier(score.active_points) : null;
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(); }}>
<div style={s.overlay} onClick={handleOverlayClick}>
<div style={s.panel}>
<div style={s.header}>
<button style={s.closeBtn} onClick={onClose}></button>
<button style={s.closeBtn} onClick={onClose}>
</button>
<div style={{ fontSize: '20px', fontWeight: 700 }}>
{loading ? 'Loading…' : (employee?.name || 'Employee Profile')}
{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(' · ')}
{[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>
) : (<>
<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>
<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>
)}
{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>
<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>
))}
</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>
</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>
) : (
<button style={s.actionBtn('#c0392b')} onClick={() => setConfirmDel(v.id)}> Delete</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</>)}
{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>

View File

@@ -8,7 +8,7 @@ const s = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
zIndex: 2000,
},
modal: {
width: '480px',
@@ -115,6 +115,7 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
if (!violation) return null;
const handleConfirm = () => {
if (!onConfirm) return;
onConfirm({
resolution_type: resolutionType,
details,
@@ -122,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(); }}>
<div style={s.overlay} onClick={handleOverlayClick}>
<div style={s.modal}>
<div style={s.header}>
<div style={s.title}> Negate Violation Points</div>
@@ -173,10 +178,18 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
</div>
<div style={s.footer}>
<button type="button" style={s.btnCancel} onClick={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>