diff --git a/client/src/components/AmendViolationModal.jsx b/client/src/components/AmendViolationModal.jsx new file mode 100644 index 0000000..c9dbd91 --- /dev/null +++ b/client/src/components/AmendViolationModal.jsx @@ -0,0 +1,205 @@ +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 ( +