) : (
@@ -208,17 +230,22 @@ export default function EmployeeModal({ employeeId, onClose }) {
{v.incident_date}
- {v.violation_name}
+
+ {v.violation_name}
+ {v.amendment_count > 0 && (
+ {v.amendment_count} edit{v.amendment_count !== 1 ? 's' : ''}
+ )}
+
{v.category}
{v.details && (
-
- {v.details}
-
+ {v.details}
)}
{v.points}
- {/* FIX: All buttons use e.stopPropagation() to prevent overlay close */}
+ { e.stopPropagation(); setAmending(v); }}>
+ Amend
+
{ e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
@@ -294,9 +321,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
)}
{v.resolved_by && (
-
- by {v.resolved_by}
-
+ by {v.resolved_by}
)}
@@ -349,7 +374,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
- {/* 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 && (
setNegating(null)}
/>
)}
+ {editingEmp && employee && (
+ setEditingEmp(false)}
+ onSaved={load}
+ />
+ )}
+ {amending && (
+ setAmending(null)}
+ onSaved={load}
+ />
+ )}
);
}
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
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(`