feat: add acknowledge endpoint, supervisor dashboard filter, analytics endpoints
This commit is contained in:
140
server.js
140
server.js
@@ -11,7 +11,7 @@ app.use(cors());
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
||||||
|
|
||||||
// ── Audit helper ─────────────────────────────────────────────────────────────
|
// ── Audit helper ────────────────────────────────────────────────────────────
|
||||||
function audit(action, entityType, entityId, performedBy, details) {
|
function audit(action, entityType, entityId, performedBy, details) {
|
||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -27,7 +27,7 @@ function audit(action, entityType, entityId, performedBy, details) {
|
|||||||
// Health
|
// Health
|
||||||
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
// ── Employees ─────────────────────────────────────────────────────────────────
|
// ── Employees ────────────────────────────────────────────────────────────────
|
||||||
app.get('/api/employees', (req, res) => {
|
app.get('/api/employees', (req, res) => {
|
||||||
const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
|
const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
@@ -50,7 +50,7 @@ app.post('/api/employees', (req, res) => {
|
|||||||
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Employee Edit ─────────────────────────────────────────────────────────────
|
// ── Employee Edit ────────────────────────────────────────────────────────────
|
||||||
// PATCH /api/employees/:id — update name, department, supervisor, or notes
|
// PATCH /api/employees/:id — update name, department, supervisor, or notes
|
||||||
app.patch('/api/employees/:id', (req, res) => {
|
app.patch('/api/employees/:id', (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
@@ -81,7 +81,7 @@ app.patch('/api/employees/:id', (req, res) => {
|
|||||||
res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes });
|
res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Employee Merge ────────────────────────────────────────────────────────────
|
// ── Employee Merge ───────────────────────────────────────────────────────────
|
||||||
// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source
|
// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source
|
||||||
app.post('/api/employees/:id/merge', (req, res) => {
|
app.post('/api/employees/:id/merge', (req, res) => {
|
||||||
const targetId = parseInt(req.params.id);
|
const targetId = parseInt(req.params.id);
|
||||||
@@ -95,9 +95,7 @@ app.post('/api/employees/:id/merge', (req, res) => {
|
|||||||
if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' });
|
if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' });
|
||||||
|
|
||||||
const mergeTransaction = db.transaction(() => {
|
const mergeTransaction = db.transaction(() => {
|
||||||
// Move all violations
|
|
||||||
const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id);
|
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);
|
db.prepare('DELETE FROM employees WHERE id = ?').run(source_id);
|
||||||
return moved.changes;
|
return moved.changes;
|
||||||
});
|
});
|
||||||
@@ -113,7 +111,7 @@ app.post('/api/employees/:id/merge', (req, res) => {
|
|||||||
res.json({ success: true, violations_reassigned: violationsMoved });
|
res.json({ success: true, violations_reassigned: violationsMoved });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Employee notes (PATCH shorthand) ─────────────────────────────────────────
|
// ── Employee notes (PATCH shorthand) ────────────────────────────────────────
|
||||||
// PATCH /api/employees/:id/notes — save free-text notes only
|
// PATCH /api/employees/:id/notes — save free-text notes only
|
||||||
app.patch('/api/employees/:id/notes', (req, res) => {
|
app.patch('/api/employees/:id/notes', (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
@@ -134,9 +132,8 @@ app.get('/api/employees/:id/score', (req, res) => {
|
|||||||
res.json(row || { employee_id: req.params.id, active_points: 0, violation_count: 0 });
|
res.json(row || { employee_id: req.params.id, active_points: 0, violation_count: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expiration Timeline ───────────────────────────────────────────────────────
|
// ── Expiration Timeline ──────────────────────────────────────────────────────
|
||||||
// GET /api/employees/:id/expiration — active violations sorted by roll-off date
|
// GET /api/employees/:id/expiration — active violations sorted by roll-off date
|
||||||
// Returns each active violation with days_remaining until it exits the 90-day window.
|
|
||||||
app.get('/api/employees/:id/expiration', (req, res) => {
|
app.get('/api/employees/:id/expiration', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -146,12 +143,12 @@ app.get('/api/employees/:id/expiration', (req, res) => {
|
|||||||
v.category,
|
v.category,
|
||||||
v.points,
|
v.points,
|
||||||
v.incident_date,
|
v.incident_date,
|
||||||
DATE(v.incident_date, '+90 days') AS expires_on,
|
DATE(v.incident_date, '+90 days') AS expires_on,
|
||||||
CAST(
|
CAST(
|
||||||
JULIANDAY(DATE(v.incident_date, '+90 days')) -
|
JULIANDAY(DATE(v.incident_date, '+90 days')) -
|
||||||
JULIANDAY(DATE('now'))
|
JULIANDAY(DATE('now'))
|
||||||
AS INTEGER
|
AS INTEGER
|
||||||
) AS days_remaining
|
) AS days_remaining
|
||||||
FROM violations v
|
FROM violations v
|
||||||
WHERE v.employee_id = ?
|
WHERE v.employee_id = ?
|
||||||
AND v.negated = 0
|
AND v.negated = 0
|
||||||
@@ -161,15 +158,88 @@ app.get('/api/employees/:id/expiration', (req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dashboard
|
// ── Dashboard ────────────────────────────────────────────────────────────────
|
||||||
|
// Optional ?supervisor= filter for scoped views
|
||||||
app.get('/api/dashboard', (req, res) => {
|
app.get('/api/dashboard', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const supervisor = req.query.supervisor || null;
|
||||||
|
let sql = `
|
||||||
SELECT e.id, e.name, e.department, e.supervisor,
|
SELECT e.id, e.name, e.department, e.supervisor,
|
||||||
COALESCE(s.active_points, 0) AS active_points,
|
COALESCE(s.active_points, 0) AS active_points,
|
||||||
COALESCE(s.violation_count,0) AS violation_count
|
COALESCE(s.violation_count,0) AS violation_count
|
||||||
FROM employees e
|
FROM employees e
|
||||||
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
|
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
|
||||||
ORDER BY active_points DESC, e.name ASC
|
`;
|
||||||
|
const args = [];
|
||||||
|
if (supervisor) {
|
||||||
|
sql += ` WHERE LOWER(e.supervisor) = LOWER(?)`;
|
||||||
|
args.push(supervisor);
|
||||||
|
}
|
||||||
|
sql += ` ORDER BY active_points DESC, e.name ASC`;
|
||||||
|
res.json(db.prepare(sql).all(...args));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Analytics: Violation Trends ─────────────────────────────────────────────
|
||||||
|
// GET /api/analytics/trends — weekly violation counts (last 52 weeks)
|
||||||
|
// Optional ?supervisor=, ?department=
|
||||||
|
app.get('/api/analytics/trends', (req, res) => {
|
||||||
|
const { supervisor, department } = req.query;
|
||||||
|
const where = ['v.negated = 0'];
|
||||||
|
const args = [];
|
||||||
|
if (supervisor) { where.push("LOWER(e.supervisor) = LOWER(?)"); args.push(supervisor); }
|
||||||
|
if (department) { where.push("LOWER(e.department) = LOWER(?)"); args.push(department); }
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%W', v.incident_date) AS week,
|
||||||
|
COUNT(*) AS violation_count,
|
||||||
|
SUM(v.points) AS total_points
|
||||||
|
FROM violations v
|
||||||
|
JOIN employees e ON e.id = v.employee_id
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
AND v.incident_date >= DATE('now', '-365 days')
|
||||||
|
GROUP BY week
|
||||||
|
ORDER BY week ASC
|
||||||
|
`).all(...args);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Analytics: Department Summary ───────────────────────────────────────────
|
||||||
|
// GET /api/analytics/departments — aggregated stats per department
|
||||||
|
app.get('/api/analytics/departments', (req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(e.department, 'Unassigned') AS department,
|
||||||
|
COUNT(DISTINCT e.id) AS employee_count,
|
||||||
|
COALESCE(SUM(s.active_points), 0) AS total_active_points,
|
||||||
|
COALESCE(SUM(s.violation_count), 0) AS total_violations,
|
||||||
|
ROUND(
|
||||||
|
CAST(COALESCE(SUM(s.active_points), 0) AS FLOAT) /
|
||||||
|
NULLIF(COUNT(DISTINCT e.id), 0)
|
||||||
|
, 1) AS avg_points_per_employee
|
||||||
|
FROM employees e
|
||||||
|
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
|
||||||
|
GROUP BY COALESCE(e.department, 'Unassigned')
|
||||||
|
ORDER BY total_active_points DESC
|
||||||
|
`).all();
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Analytics: Recurrence Heatmap ───────────────────────────────────────────
|
||||||
|
// GET /api/analytics/recurrence — violation type counts per employee (90-day active only)
|
||||||
|
app.get('/api/analytics/recurrence', (req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
e.id AS employee_id,
|
||||||
|
e.name AS employee_name,
|
||||||
|
v.violation_type,
|
||||||
|
v.violation_name,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM violations v
|
||||||
|
JOIN employees e ON e.id = v.employee_id
|
||||||
|
WHERE v.negated = 0
|
||||||
|
AND v.incident_date >= DATE('now', '-90 days')
|
||||||
|
GROUP BY e.id, v.violation_type
|
||||||
|
ORDER BY count DESC
|
||||||
`).all();
|
`).all();
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -190,7 +260,7 @@ app.get('/api/violations/employee/:id', (req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Violation amendment history ───────────────────────────────────────────────
|
// ── Violation amendment history ──────────────────────────────────────────────
|
||||||
app.get('/api/violations/:id/amendments', (req, res) => {
|
app.get('/api/violations/:id/amendments', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC
|
SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC
|
||||||
@@ -216,7 +286,8 @@ app.post('/api/violations', (req, res) => {
|
|||||||
const {
|
const {
|
||||||
employee_id, violation_type, violation_name, category,
|
employee_id, violation_type, violation_name, category,
|
||||||
points, incident_date, incident_time, location,
|
points, incident_date, incident_time, location,
|
||||||
details, submitted_by, witness_name
|
details, submitted_by, witness_name,
|
||||||
|
acknowledged_by, acknowledged_at,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!employee_id || !violation_type || !points || !incident_date) {
|
if (!employee_id || !violation_type || !points || !incident_date) {
|
||||||
@@ -231,13 +302,15 @@ app.post('/api/violations', (req, res) => {
|
|||||||
employee_id, violation_type, violation_name, category,
|
employee_id, violation_type, violation_name, category,
|
||||||
points, incident_date, incident_time, location,
|
points, incident_date, incident_time, location,
|
||||||
details, submitted_by, witness_name,
|
details, submitted_by, witness_name,
|
||||||
|
acknowledged_by, acknowledged_at,
|
||||||
prior_active_points
|
prior_active_points
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
employee_id, violation_type, violation_name || violation_type,
|
employee_id, violation_type, violation_name || violation_type,
|
||||||
category || 'General', ptsInt, incident_date,
|
category || 'General', ptsInt, incident_date,
|
||||||
incident_time || null, location || null,
|
incident_time || null, location || null,
|
||||||
details || null, submitted_by || null, witness_name || null,
|
details || null, submitted_by || null, witness_name || null,
|
||||||
|
acknowledged_by || null, acknowledged_at || null,
|
||||||
priorPts
|
priorPts
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -248,9 +321,9 @@ app.post('/api/violations', (req, res) => {
|
|||||||
res.status(201).json({ id: result.lastInsertRowid });
|
res.status(201).json({ id: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Violation Amendment (edit) ────────────────────────────────────────────────
|
// ── Violation Amendment (edit) ───────────────────────────────────────────────
|
||||||
// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field
|
// 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'];
|
const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_at'];
|
||||||
|
|
||||||
app.patch('/api/violations/:id/amend', (req, res) => {
|
app.patch('/api/violations/:id/amend', (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
@@ -295,7 +368,26 @@ app.patch('/api/violations/:id/amend', (req, res) => {
|
|||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Negate a violation ────────────────────────────────────────────────────────
|
// ── Acknowledge a violation (dedicated endpoint) ─────────────────────────────
|
||||||
|
// PATCH /api/violations/:id/acknowledge — record employee receipt name + date
|
||||||
|
app.patch('/api/violations/:id/acknowledge', (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { acknowledged_by, acknowledged_at, performed_by } = req.body;
|
||||||
|
|
||||||
|
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
|
||||||
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE violations SET acknowledged_by = ?, acknowledged_at = ? WHERE id = ?')
|
||||||
|
.run(acknowledged_by || null, acknowledged_at || null, id);
|
||||||
|
|
||||||
|
audit('violation_acknowledged', 'violation', id, performed_by || acknowledged_by, {
|
||||||
|
acknowledged_by, acknowledged_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, acknowledged_by, acknowledged_at });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Negate a violation ───────────────────────────────────────────────────────
|
||||||
app.patch('/api/violations/:id/negate', (req, res) => {
|
app.patch('/api/violations/:id/negate', (req, res) => {
|
||||||
const { resolution_type, details, resolved_by } = req.body;
|
const { resolution_type, details, resolved_by } = req.body;
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
@@ -323,7 +415,7 @@ app.patch('/api/violations/:id/negate', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Restore a negated violation ───────────────────────────────────────────────
|
// ── Restore a negated violation ──────────────────────────────────────────────
|
||||||
app.patch('/api/violations/:id/restore', (req, res) => {
|
app.patch('/api/violations/:id/restore', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|
||||||
@@ -337,7 +429,7 @@ app.patch('/api/violations/:id/restore', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Hard delete a violation ───────────────────────────────────────────────────
|
// ── Hard delete a violation ──────────────────────────────────────────────────
|
||||||
app.delete('/api/violations/:id', (req, res) => {
|
app.delete('/api/violations/:id', (req, res) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
|
||||||
@@ -353,7 +445,7 @@ app.delete('/api/violations/:id', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Audit log ─────────────────────────────────────────────────────────────────
|
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||||
app.get('/api/audit', (req, res) => {
|
app.get('/api/audit', (req, res) => {
|
||||||
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
|
||||||
const offset = parseInt(req.query.offset) || 0;
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
@@ -372,7 +464,7 @@ app.get('/api/audit', (req, res) => {
|
|||||||
res.json(db.prepare(sql).all(...args));
|
res.json(db.prepare(sql).all(...args));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PDF endpoint ──────────────────────────────────────────────────────────────
|
// ── PDF endpoint ─────────────────────────────────────────────────────────────
|
||||||
app.get('/api/violations/:id/pdf', async (req, res) => {
|
app.get('/api/violations/:id/pdf', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const violation = db.prepare(`
|
const violation = db.prepare(`
|
||||||
|
|||||||
Reference in New Issue
Block a user