From d6585c01c686c1c7fdf56779abc0f9ed16e1a1db Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 11 Mar 2026 16:33:38 -0500 Subject: [PATCH 01/13] Code review and fixes --- client/src/App.jsx | 11 +++- client/src/components/AuditLog.jsx | 6 +- client/src/components/Dashboard.jsx | 4 +- client/src/components/EmployeeModal.jsx | 5 +- client/src/components/EmployeeNotes.jsx | 15 +++++ client/src/components/ExpirationTimeline.jsx | 4 +- client/src/components/NegateModal.jsx | 2 - client/src/components/ViolationForm.jsx | 4 +- server.js | 65 ++++++++++++++++++-- 9 files changed, 98 insertions(+), 18 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 2b02499..4c627af 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -6,6 +6,8 @@ import ToastProvider from './components/ToastProvider'; import './styles/mobile.css'; const REPO_URL = 'https://git.alwisp.com/jason/cpas'; +// TODO [CLEANUP #18]: DevTicker is a dev vanity widget that ships to prod. +// Either gate with `import.meta.env.DEV` or remove from the footer. const PROJECT_START = new Date('2026-03-06T11:33:32-06:00'); function elapsed(from) { @@ -101,7 +103,9 @@ const tabs = [ { id: 'violation', label: '+ New Violation' }, ]; -// Responsive utility hook +// TODO [MAJOR #8]: Move to src/hooks/useMediaQuery.js β€” this hook is duplicated +// verbatim in Dashboard.jsx. Also remove `matches` from the useEffect dep array +// (it changes inside the effect, which can cause a loop on strict-mode mount). function useMediaQuery(query) { const [matches, setMatches] = useState(false); useEffect(() => { @@ -237,6 +241,8 @@ export default function App() { return ( + {/* TODO [MAJOR #9]: Inline
+ +
+
+ {tab === 'dashboard' ? : } +
+
+ + + + {showReadme && setShowReadme(false)} />} +
+
+ ); +} diff --git a/.claude/worktrees/musing-bell/client/src/components/AmendViolationModal.jsx b/.claude/worktrees/musing-bell/client/src/components/AmendViolationModal.jsx new file mode 100644 index 0000000..c9dbd91 --- /dev/null +++ b/.claude/worktrees/musing-bell/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 ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Amend Violation
+
+ CPAS-{String(violation.id).padStart(5, '0')} Β· {violation.violation_name} Β· {violation.incident_date} +
+
+ +
+ +
+
+ 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. +
+ + {error &&
{error}
} + + {Object.entries(FIELD_LABELS).map(([field, label]) => ( +
+
{label}
+ {field === 'details' ? ( +