From 9b6f2353be7aee7c06a229bd73c6e1396faa9b07 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:22:01 -0600 Subject: [PATCH 1/7] feat(db): add violation_amendments and audit_log tables --- db/database.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/db/database.js b/db/database.js index a29416f..cba0200 100755 --- a/db/database.js +++ b/db/database.js @@ -13,12 +13,12 @@ db.pragma('foreign_keys = ON'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); db.exec(schema); -// ── Migrations for existing DBs ───────────────────────────────────────────── +// ── Migrations for existing DBs ────────────────────────────────────────────── const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name); -if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); -if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); -if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER"); -if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT"); +if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); +if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); +if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER"); +if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT"); // Ensure resolutions table exists db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( @@ -30,6 +30,30 @@ db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); +// ── Feature: Violation Amendments ──────────────────────────────────────────── +// Stores a field-level diff every time a violation's editable fields are changed. +db.exec(`CREATE TABLE IF NOT EXISTS violation_amendments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE, + changed_by TEXT, + field_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +)`); + +// ── Feature: Audit Log ─────────────────────────────────────────────────────── +// Append-only record of every write action across the system. +db.exec(`CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + performed_by TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +)`); + // Recreate view so it always filters negated rows db.exec(`DROP VIEW IF EXISTS active_cpas_scores; CREATE VIEW active_cpas_scores AS -- 2.49.1 From 5004c569152f9599ae0aaaaeacaeda1552a28520 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:23:04 -0600 Subject: [PATCH 2/7] feat: employee edit/merge, violation amendment, audit log endpoints --- server.js | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 10 deletions(-) diff --git a/server.js b/server.js index f442834..0a33626 100755 --- a/server.js +++ b/server.js @@ -11,10 +11,23 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); +// ── Audit helper ───────────────────────────────────────────────────────────── +function audit(action, entityType, entityId, performedBy, details) { + try { + db.prepare(` + INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details) + VALUES (?, ?, ?, ?, ?) + `).run(action, entityType, entityId || null, performedBy || null, + typeof details === 'object' ? JSON.stringify(details) : (details || null)); + } catch (e) { + console.error('[AUDIT]', e.message); + } +} + // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); -// Employees +// ── Employees ───────────────────────────────────────────────────────────────── app.get('/api/employees', (req, res) => { const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all(); res.json(rows); @@ -33,9 +46,72 @@ app.post('/api/employees', (req, res) => { } const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)') .run(name, department || null, supervisor || null); + audit('employee_created', 'employee', result.lastInsertRowid, null, { name }); res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); +// ── Employee Edit ───────────────────────────────────────────────────────────── +// PATCH /api/employees/:id — update name, department, or supervisor +app.patch('/api/employees/:id', (req, res) => { + const id = parseInt(req.params.id); + const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id); + if (!emp) return res.status(404).json({ error: 'Employee not found' }); + + const { name, department, supervisor, performed_by } = req.body; + + // Prevent name collision with a different employee + if (name && name.trim() !== emp.name) { + const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id); + if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id }); + } + + const newName = (name || emp.name).trim(); + const newDept = department !== undefined ? (department || null) : emp.department; + const newSupervisor = supervisor !== undefined ? (supervisor || null) : emp.supervisor; + + db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ? WHERE id = ?') + .run(newName, newDept, newSupervisor, id); + + audit('employee_edited', 'employee', id, performed_by, { + before: { name: emp.name, department: emp.department, supervisor: emp.supervisor }, + after: { name: newName, department: newDept, supervisor: newSupervisor }, + }); + + res.json({ id, name: newName, department: newDept, supervisor: newSupervisor }); +}); + +// ── Employee Merge ──────────────────────────────────────────────────────────── +// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source +app.post('/api/employees/:id/merge', (req, res) => { + const targetId = parseInt(req.params.id); + const { source_id, performed_by } = req.body; + if (!source_id) return res.status(400).json({ error: 'source_id is required' }); + + const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId); + const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id); + if (!target) return res.status(404).json({ error: 'Target employee not found' }); + if (!source) return res.status(404).json({ error: 'Source employee not found' }); + if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' }); + + const mergeTransaction = db.transaction(() => { + // Move all violations + const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id); + // Delete the source employee + db.prepare('DELETE FROM employees WHERE id = ?').run(source_id); + return moved.changes; + }); + + const violationsMoved = mergeTransaction(); + + audit('employee_merged', 'employee', targetId, performed_by, { + source: { id: source.id, name: source.name }, + target: { id: target.id, name: target.name }, + violations_reassigned: violationsMoved, + }); + + res.json({ success: true, violations_reassigned: violationsMoved }); +}); + // Employee score (current snapshot) app.get('/api/employees/:id/score', (req, res) => { const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id); @@ -55,12 +131,13 @@ app.get('/api/dashboard', (req, res) => { res.json(rows); }); -// Violation history (per employee) with resolutions +// Violation history (per employee) with resolutions + amendment count app.get('/api/violations/employee/:id', (req, res) => { const limit = parseInt(req.query.limit) || 50; const rows = db.prepare(` SELECT v.*, r.resolution_type, r.details AS resolution_details, - r.resolved_by, r.created_at AS resolved_at + r.resolved_by, r.created_at AS resolved_at, + (SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count FROM violations v LEFT JOIN violation_resolutions r ON r.violation_id = v.id WHERE v.employee_id = ? @@ -70,6 +147,14 @@ app.get('/api/violations/employee/:id', (req, res) => { res.json(rows); }); +// ── Violation amendment history ─────────────────────────────────────────────── +app.get('/api/violations/:id/amendments', (req, res) => { + const rows = db.prepare(` + SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC + `).all(req.params.id); + res.json(rows); +}); + // Helper: compute prior_active_points at time of insert function getPriorActivePoints(employeeId, incidentDate) { const row = db.prepare( @@ -113,10 +198,61 @@ app.post('/api/violations', (req, res) => { priorPts ); + audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, { + employee_id, violation_type, points: ptsInt, incident_date, + }); + res.status(201).json({ id: result.lastInsertRowid }); }); -// ── Negate a violation ────────────────────────────────────────────────────── +// ── Violation Amendment (edit) ──────────────────────────────────────────────── +// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field +const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name']; + +app.patch('/api/violations/:id/amend', (req, res) => { + const id = parseInt(req.params.id); + const { changed_by, ...updates } = req.body; + + const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' }); + + // Only allow whitelisted fields to be amended + const allowed = Object.fromEntries( + Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k)) + ); + if (Object.keys(allowed).length === 0) { + return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS }); + } + + const amendTransaction = db.transaction(() => { + // Build UPDATE + const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', '); + const values = [...Object.values(allowed), id]; + db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values); + + // Insert an amendment record per changed field + const insertAmendment = db.prepare(` + INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value) + VALUES (?, ?, ?, ?, ?) + `); + for (const [field, newVal] of Object.entries(allowed)) { + const oldVal = violation[field]; + if (String(oldVal) !== String(newVal)) { + insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null); + } + } + }); + + amendTransaction(); + + audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) }); + + const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + res.json(updated); +}); + +// ── Negate a violation ──────────────────────────────────────────────────────── app.patch('/api/violations/:id/negate', (req, res) => { const { resolution_type, details, resolved_by } = req.body; const id = req.params.id; @@ -124,10 +260,8 @@ app.patch('/api/violations/:id/negate', (req, res) => { const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); - // Mark negated db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id); - // Upsert resolution record const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id); if (existing) { db.prepare(` @@ -142,10 +276,11 @@ app.patch('/api/violations/:id/negate', (req, res) => { `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null); } + audit('violation_negated', 'violation', id, resolved_by, { resolution_type }); res.json({ success: true }); }); -// ── Restore a negated violation ───────────────────────────────────────────── +// ── Restore a negated violation ─────────────────────────────────────────────── app.patch('/api/violations/:id/restore', (req, res) => { const id = req.params.id; @@ -155,24 +290,46 @@ app.patch('/api/violations/:id/restore', (req, res) => { db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id); db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); + audit('violation_restored', 'violation', id, req.body?.performed_by, {}); res.json({ success: true }); }); -// ── Hard delete a violation ───────────────────────────────────────────────── +// ── Hard delete a violation ─────────────────────────────────────────────────── app.delete('/api/violations/:id', (req, res) => { const id = req.params.id; const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); - // Delete resolution first (FK safety) db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); db.prepare('DELETE FROM violations WHERE id = ?').run(id); + audit('violation_deleted', 'violation', id, req.body?.performed_by, { + violation_type: violation.violation_type, employee_id: violation.employee_id, + }); res.json({ success: true }); }); -// ── PDF endpoint ───────────────────────────────────────────────────────────── +// ── Audit log ───────────────────────────────────────────────────────────────── +app.get('/api/audit', (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 100, 500); + const offset = parseInt(req.query.offset) || 0; + const type = req.query.entity_type; + const id = req.query.entity_id; + + let sql = 'SELECT * FROM audit_log'; + const args = []; + const where = []; + if (type) { where.push('entity_type = ?'); args.push(type); } + if (id) { where.push('entity_id = ?'); args.push(id); } + if (where.length) sql += ' WHERE ' + where.join(' AND '); + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + args.push(limit, offset); + + res.json(db.prepare(sql).all(...args)); +}); + +// ── PDF endpoint ────────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(` -- 2.49.1 From ee91a16506ea0fc3ba166a41f5a2bc937e42f5c7 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:23:39 -0600 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20EditEmployeeModal=20=E2=80=94=20edi?= =?UTF-8?q?t=20name/dept/supervisor=20and=20merge=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/EditEmployeeModal.jsx | 189 ++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 client/src/components/EditEmployeeModal.jsx diff --git a/client/src/components/EditEmployeeModal.jsx b/client/src/components/EditEmployeeModal.jsx new file mode 100644 index 0000000..a438e1b --- /dev/null +++ b/client/src/components/EditEmployeeModal.jsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; + +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: '480px', maxWidth: '95vw', + borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)', + border: '1px solid #222', overflow: 'hidden', + }, + header: { + background: 'linear-gradient(135deg, #000000, #151622)', color: 'white', + padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', + borderBottom: '1px solid #222', + }, + title: { fontSize: '15px', fontWeight: 700 }, + closeBtn: { + background: 'none', border: 'none', color: 'white', fontSize: '20px', + cursor: 'pointer', lineHeight: 1, + }, + body: { padding: '22px' }, + tabs: { display: 'flex', gap: '4px', marginBottom: '20px' }, + tab: (active) => ({ + flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer', fontSize: '12px', + fontWeight: 700, textAlign: 'center', border: '1px solid', + background: active ? '#1a1c2e' : 'none', + borderColor: active ? '#667eea' : '#2a2b3a', + color: active ? '#667eea' : '#777', + }), + 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', + }, + select: { + width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px', + outline: 'none', boxSizing: 'border-box', + }, + 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', + }, + success: { + background: '#0a2e1f', border: '1px solid #0f5132', borderRadius: '6px', + padding: '10px 12px', fontSize: '12px', color: '#9ef7c1', marginBottom: '14px', + }, + mergeWarning: { + background: '#2a1f00', border: '1px solid #7a5000', borderRadius: '6px', + padding: '12px', fontSize: '12px', color: '#ffc107', marginBottom: '14px', lineHeight: 1.5, + }, +}; + +export default function EditEmployeeModal({ employee, onClose, onSaved }) { + const [tab, setTab] = useState('edit'); + + // Edit state + const [name, setName] = useState(employee.name); + const [department, setDepartment] = useState(employee.department || ''); + const [supervisor, setSupervisor] = useState(employee.supervisor || ''); + const [editError, setEditError] = useState(''); + const [editSaving, setEditSaving] = useState(false); + + // Merge state + const [allEmployees, setAllEmployees] = useState([]); + const [sourceId, setSourceId] = useState(''); + const [mergeError, setMergeError] = useState(''); + const [mergeResult, setMergeResult] = useState(null); + const [merging, setMerging] = useState(false); + + useEffect(() => { + if (tab === 'merge') { + axios.get('/api/employees').then(r => setAllEmployees(r.data)); + } + }, [tab]); + + const handleEdit = async () => { + setEditError(''); + setEditSaving(true); + try { + await axios.patch(`/api/employees/${employee.id}`, { name, department, supervisor }); + onSaved(); + onClose(); + } catch (e) { + setEditError(e.response?.data?.error || 'Failed to save changes'); + } finally { + setEditSaving(false); + } + }; + + const handleMerge = async () => { + if (!sourceId) return setMergeError('Select an employee to merge in'); + setMergeError(''); + setMerging(true); + try { + const r = await axios.post(`/api/employees/${employee.id}/merge`, { source_id: parseInt(sourceId) }); + setMergeResult(r.data); + onSaved(); // refresh dashboard / parent list + } catch (e) { + setMergeError(e.response?.data?.error || 'Merge failed'); + } finally { + setMerging(false); + } + }; + + const otherEmployees = allEmployees.filter(e => e.id !== employee.id); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
Edit Employee
+ +
+
+
+ + +
+ + {tab === 'edit' && ( + <> + {editError &&
{editError}
} +
Full Name
+ setName(e.target.value)} /> +
Department
+ setDepartment(e.target.value)} placeholder="Optional" /> +
Supervisor
+ setSupervisor(e.target.value)} placeholder="Optional" /> +
+ + +
+ + )} + + {tab === 'merge' && ( + <> + {mergeResult ? ( +
+ ✓ Merge complete — {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned + to {employee.name}. The duplicate record has been removed. +
+ ) : ( + <> +
+ ⚠ This will reassign all violations from the selected employee into{' '} + {employee.name}, then permanently delete the duplicate record. + This cannot be undone. +
+ {mergeError &&
{mergeError}
} +
Duplicate to merge into {employee.name}
+ +
+ + +
+ + )} + {mergeResult && ( +
+ +
+ )} + + )} +
+
+
+ ); +} -- 2.49.1 From 15d3f028840fa2532ad55b2ac4e12d9a73b76ec8 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:24:13 -0600 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20AmendViolationModal=20=E2=80=94=20e?= =?UTF-8?q?dit=20non-scoring=20fields=20with=20full=20diff=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/AmendViolationModal.jsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 client/src/components/AmendViolationModal.jsx 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 ( +
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' ? ( +