roadmap #23
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import CpasBadge, { getTier } from './CpasBadge';
|
import CpasBadge, { getTier } from './CpasBadge';
|
||||||
import NegateModal from './NegateModal';
|
import NegateModal from './NegateModal';
|
||||||
|
import EditEmployeeModal from './EditEmployeeModal';
|
||||||
|
import AmendViolationModal from './AmendViolationModal';
|
||||||
|
|
||||||
const s = {
|
const s = {
|
||||||
overlay: {
|
overlay: {
|
||||||
@@ -18,10 +20,15 @@ const s = {
|
|||||||
padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10,
|
padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10,
|
||||||
borderBottom: '1px solid #222',
|
borderBottom: '1px solid #222',
|
||||||
},
|
},
|
||||||
|
headerRow: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' },
|
||||||
closeBtn: {
|
closeBtn: {
|
||||||
float: 'right', background: 'none', border: 'none', color: 'white',
|
float: 'right', background: 'none', border: 'none', color: 'white',
|
||||||
fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px',
|
fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px',
|
||||||
},
|
},
|
||||||
|
editEmpBtn: {
|
||||||
|
background: 'none', border: '1px solid #555', color: '#ccc', borderRadius: '5px',
|
||||||
|
padding: '4px 10px', fontSize: '11px', cursor: 'pointer', marginTop: '8px', fontWeight: 600,
|
||||||
|
},
|
||||||
body: { padding: '24px 28px', flex: 1 },
|
body: { padding: '24px 28px', flex: 1 },
|
||||||
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
|
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
|
||||||
scoreCard: {
|
scoreCard: {
|
||||||
@@ -62,19 +69,31 @@ const s = {
|
|||||||
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
||||||
cursor: 'pointer', fontWeight: 600,
|
cursor: 'pointer', fontWeight: 600,
|
||||||
},
|
},
|
||||||
|
amendBtn: {
|
||||||
|
background: 'none', border: '1px solid #4db6ac', color: '#4db6ac',
|
||||||
|
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
|
||||||
|
cursor: 'pointer', marginRight: '4px', fontWeight: 600,
|
||||||
|
},
|
||||||
deleteConfirm: {
|
deleteConfirm: {
|
||||||
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
|
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
|
||||||
padding: '12px', marginTop: '8px', fontSize: '12px', color: '#ffb3b8',
|
padding: '12px', marginTop: '8px', fontSize: '12px', color: '#ffb3b8',
|
||||||
},
|
},
|
||||||
|
amendBadge: {
|
||||||
|
display: 'inline-block', marginLeft: '4px', padding: '1px 5px', borderRadius: '8px',
|
||||||
|
fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac',
|
||||||
|
border: '1px solid #1a4a4a', verticalAlign: 'middle',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmployeeModal({ employeeId, onClose }) {
|
export default function EmployeeModal({ employeeId, onClose }) {
|
||||||
const [employee, setEmployee] = useState(null);
|
const [employee, setEmployee] = useState(null);
|
||||||
const [score, setScore] = useState(null);
|
const [score, setScore] = useState(null);
|
||||||
const [violations, setViolations] = useState([]);
|
const [violations, setViolations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [negating, setNegating] = useState(null);
|
const [negating, setNegating] = useState(null);
|
||||||
const [confirmDel, setConfirmDel] = useState(null);
|
const [confirmDel, setConfirmDel] = useState(null);
|
||||||
|
const [editingEmp, setEditingEmp] = useState(false);
|
||||||
|
const [amending, setAmending] = useState(null); // violation object
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -96,9 +115,9 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
|
|
||||||
const handleDownloadPdf = async (violId, empName, date) => {
|
const handleDownloadPdf = async (violId, empName, date) => {
|
||||||
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
|
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
|
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@@ -119,39 +138,42 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
|
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
|
||||||
await axios.patch(`/api/violations/${negating.id}/negate`, {
|
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
|
||||||
resolution_type, details, resolved_by,
|
|
||||||
});
|
|
||||||
setNegating(null);
|
setNegating(null);
|
||||||
setConfirmDel(null);
|
setConfirmDel(null);
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const tier = score ? getTier(score.active_points) : null;
|
const tier = score ? getTier(score.active_points) : null;
|
||||||
const active = violations.filter((v) => !v.negated);
|
const active = violations.filter((v) => !v.negated);
|
||||||
const negated = 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(); };
|
||||||
const handleOverlayClick = (e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// FIX: panel uses onClick stopPropagation to prevent bubbling to overlay
|
|
||||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||||
<div style={s.panel} onClick={(e) => e.stopPropagation()}>
|
<div style={s.panel} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div style={s.header}>
|
<div style={s.header}>
|
||||||
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
<div style={s.headerRow}>
|
||||||
<div style={{ fontSize: '18px', fontWeight: 700 }}>
|
<div>
|
||||||
{employee ? employee.name : 'Employee'}
|
<div style={{ fontSize: '18px', fontWeight: 700 }}>
|
||||||
</div>
|
{employee ? employee.name : 'Employee'}
|
||||||
{employee && (
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#b5b5c0', marginTop: '4px' }}>
|
{employee && (
|
||||||
{employee.department} {employee.supervisor && `· Supervisor: ${employee.supervisor}`}
|
<div style={{ fontSize: '12px', color: '#b5b5c0', marginTop: '4px' }}>
|
||||||
|
{employee.department} {employee.supervisor && `· Supervisor: ${employee.supervisor}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{employee && (
|
||||||
|
<button style={s.editEmpBtn} onClick={() => setEditingEmp(true)}>
|
||||||
|
✎ Edit Employee
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
@@ -190,7 +212,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
{/* ── Active Violations ── */}
|
{/* ── Active Violations ── */}
|
||||||
<div style={s.sectionHd}>Active Violations</div>
|
<div style={s.sectionHd}>Active Violations</div>
|
||||||
{active.length === 0 ? (
|
{active.length === 0 ? (
|
||||||
<div style={{ color: '#77798a', fontStyle: 'italic', fontSize: '12px' }}>
|
<div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}>
|
||||||
No active violations on record.
|
No active violations on record.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -208,17 +230,22 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
<tr key={v.id}>
|
<tr key={v.id}>
|
||||||
<td style={s.td}>{v.incident_date}</td>
|
<td style={s.td}>{v.incident_date}</td>
|
||||||
<td style={s.td}>
|
<td style={s.td}>
|
||||||
<div style={{ fontWeight: 600 }}>{v.violation_name}</div>
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{v.violation_name}
|
||||||
|
{v.amendment_count > 0 && (
|
||||||
|
<span style={s.amendBadge}>{v.amendment_count} edit{v.amendment_count !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
|
||||||
{v.details && (
|
{v.details && (
|
||||||
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>
|
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>{v.details}</div>
|
||||||
{v.details}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...s.td, fontWeight: 700 }}>{v.points}</td>
|
<td style={{ ...s.td, fontWeight: 700 }}>{v.points}</td>
|
||||||
<td style={s.td}>
|
<td style={s.td}>
|
||||||
{/* FIX: All buttons use e.stopPropagation() to prevent overlay close */}
|
<button style={s.amendBtn} onClick={(e) => { e.stopPropagation(); setAmending(v); }}>
|
||||||
|
Amend
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
style={s.actionBtn('#ffc107')}
|
style={s.actionBtn('#ffc107')}
|
||||||
onClick={(e) => { e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
|
onClick={(e) => { e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
|
||||||
@@ -294,9 +321,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{v.resolved_by && (
|
{v.resolved_by && (
|
||||||
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>
|
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>by {v.resolved_by}</div>
|
||||||
by {v.resolved_by}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={s.td}>
|
<td style={s.td}>
|
||||||
@@ -349,7 +374,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FIX: NegateModal rendered OUTSIDE the panel so it sits at root z-index:2000 */}
|
{/* Modals rendered outside panel to avoid z-index nesting issues */}
|
||||||
{negating && (
|
{negating && (
|
||||||
<NegateModal
|
<NegateModal
|
||||||
violation={negating}
|
violation={negating}
|
||||||
@@ -357,6 +382,20 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
|||||||
onCancel={() => setNegating(null)}
|
onCancel={() => setNegating(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editingEmp && employee && (
|
||||||
|
<EditEmployeeModal
|
||||||
|
employee={employee}
|
||||||
|
onClose={() => setEditingEmp(false)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{amending && (
|
||||||
|
<AmendViolationModal
|
||||||
|
violation={amending}
|
||||||
|
onClose={() => setAmending(null)}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user