206 lines
7.8 KiB
JavaScript
206 lines
7.8 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
|
|
const FIELD_LABELS = {
|
|
incident_time: 'Incident Time',
|
|
location: 'Location / Context',
|
|
details: 'Incident Notes',
|
|
submitted_by: 'Submitted By',
|
|
witness_name: 'Witness / Documenting Officer',
|
|
};
|
|
|
|
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: '520px', maxWidth: '95vw',
|
|
maxHeight: '90vh', overflowY: 'auto',
|
|
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
|
|
border: '1px solid #222',
|
|
},
|
|
header: {
|
|
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
|
|
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: '1px solid #222', position: 'sticky', top: 0, zIndex: 10,
|
|
},
|
|
headerLeft: {},
|
|
title: { fontSize: '15px', fontWeight: 700 },
|
|
subtitle: { fontSize: '11px', color: '#9ca0b8', marginTop: '2px' },
|
|
closeBtn: {
|
|
background: 'none', border: 'none', color: 'white', fontSize: '20px',
|
|
cursor: 'pointer', lineHeight: 1,
|
|
},
|
|
body: { padding: '22px' },
|
|
notice: {
|
|
background: '#0e1a30', border: '1px solid #1e3a5f', borderRadius: '6px',
|
|
padding: '10px 14px', fontSize: '12px', color: '#7eb8f7', marginBottom: '18px',
|
|
},
|
|
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',
|
|
},
|
|
textarea: {
|
|
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
|
|
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
|
|
outline: 'none', boxSizing: 'border-box', minHeight: '80px', resize: 'vertical',
|
|
},
|
|
divider: { borderTop: '1px solid #1c1d29', margin: '16px 0' },
|
|
sectionTitle: {
|
|
fontSize: '11px', fontWeight: 700, color: '#9ca0b8', textTransform: 'uppercase',
|
|
letterSpacing: '0.5px', marginBottom: '12px',
|
|
},
|
|
amendRow: {
|
|
background: '#0d0e14', border: '1px solid #1c1d29', borderRadius: '6px',
|
|
padding: '10px 12px', marginBottom: '8px', fontSize: '12px',
|
|
},
|
|
amendField: { fontWeight: 700, color: '#c0c2d6', marginBottom: '4px' },
|
|
amendOld: { color: '#ff7070', textDecoration: 'line-through', marginRight: '6px' },
|
|
amendNew: { color: '#9ef7c1' },
|
|
amendMeta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
|
|
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',
|
|
},
|
|
};
|
|
|
|
function fmtDt(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
|
|
}
|
|
|
|
export default function AmendViolationModal({ violation, onClose, onSaved }) {
|
|
const [fields, setFields] = useState({
|
|
incident_time: violation.incident_time || '',
|
|
location: violation.location || '',
|
|
details: violation.details || '',
|
|
submitted_by: violation.submitted_by || '',
|
|
witness_name: violation.witness_name || '',
|
|
});
|
|
const [changedBy, setChangedBy] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [amendments, setAmendments] = useState([]);
|
|
|
|
useEffect(() => {
|
|
axios.get(`/api/violations/${violation.id}/amendments`)
|
|
.then(r => setAmendments(r.data))
|
|
.catch(() => {});
|
|
}, [violation.id]);
|
|
|
|
const hasChanges = Object.entries(fields).some(
|
|
([k, v]) => v !== (violation[k] || '')
|
|
);
|
|
|
|
const handleSave = async () => {
|
|
setError('');
|
|
setSaving(true);
|
|
try {
|
|
// Only send fields that actually changed
|
|
const patch = Object.fromEntries(
|
|
Object.entries(fields).filter(([k, v]) => v !== (violation[k] || ''))
|
|
);
|
|
await axios.patch(`/api/violations/${violation.id}/amend`, { ...patch, changed_by: changedBy || null });
|
|
onSaved();
|
|
onClose();
|
|
} catch (e) {
|
|
setError(e.response?.data?.error || 'Failed to save amendment');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const set = (field, value) => setFields(prev => ({ ...prev, [field]: value }));
|
|
|
|
return (
|
|
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
|
|
<div style={s.modal}>
|
|
<div style={s.header}>
|
|
<div style={s.headerLeft}>
|
|
<div style={s.title}>Amend Violation</div>
|
|
<div style={s.subtitle}>
|
|
CPAS-{String(violation.id).padStart(5, '0')} · {violation.violation_name} · {violation.incident_date}
|
|
</div>
|
|
</div>
|
|
<button style={s.closeBtn} onClick={onClose}>✕</button>
|
|
</div>
|
|
|
|
<div style={s.body}>
|
|
<div style={s.notice}>
|
|
Only non-scoring fields can be amended. Point values, violation type, and incident date
|
|
are immutable — delete and re-submit if those need to change.
|
|
</div>
|
|
|
|
{error && <div style={s.error}>{error}</div>}
|
|
|
|
{Object.entries(FIELD_LABELS).map(([field, label]) => (
|
|
<div key={field}>
|
|
<div style={s.label}>{label}</div>
|
|
{field === 'details' ? (
|
|
<textarea
|
|
style={s.textarea}
|
|
value={fields[field]}
|
|
onChange={e => set(field, e.target.value)}
|
|
/>
|
|
) : (
|
|
<input
|
|
style={s.input}
|
|
value={fields[field]}
|
|
onChange={e => set(field, e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<div style={s.label}>Your Name (recorded in amendment log)</div>
|
|
<input
|
|
style={s.input}
|
|
value={changedBy}
|
|
onChange={e => setChangedBy(e.target.value)}
|
|
placeholder="Optional but recommended"
|
|
/>
|
|
|
|
<div style={s.row}>
|
|
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
|
|
<button
|
|
style={s.btn('#fff', hasChanges ? '#667eea' : '#333')}
|
|
onClick={handleSave}
|
|
disabled={!hasChanges || saving}
|
|
>
|
|
{saving ? 'Saving…' : 'Save Amendment'}
|
|
</button>
|
|
</div>
|
|
|
|
{amendments.length > 0 && (
|
|
<>
|
|
<div style={s.divider} />
|
|
<div style={s.sectionTitle}>Amendment History ({amendments.length})</div>
|
|
{amendments.map(a => (
|
|
<div key={a.id} style={s.amendRow}>
|
|
<div style={s.amendField}>{FIELD_LABELS[a.field_name] || a.field_name}</div>
|
|
<div>
|
|
<span style={s.amendOld}>{a.old_value || '(empty)'}</span>
|
|
<span style={{ color: '#555', marginRight: '6px' }}>→</span>
|
|
<span style={s.amendNew}>{a.new_value || '(empty)'}</span>
|
|
</div>
|
|
<div style={s.amendMeta}>
|
|
{a.changed_by ? `by ${a.changed_by} · ` : ''}{fmtDt(a.created_at)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|