Merge pull request 'feature/ack-signature-and-toasts' (#29) from feature/ack-signature-and-toasts into master

Reviewed-on: https://git.alwisp.com/jason/cpas/pulls/29
This commit was merged in pull request #29.
This commit is contained in:
jason
2026-03-07 21:47:24 -06:00
10 changed files with 431 additions and 355 deletions
+23 -6
View File
@@ -132,18 +132,21 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
- Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type - Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type
- **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission - **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission
- Point slider for discretionary adjustments within the violation's min/max range - Point slider for discretionary adjustments within the violation's min/max range
- **Employee Acknowledgment section**: optional "received by employee" name and date fields; when filled, the PDF signature block shows the recorded acknowledgment instead of a blank signature line
- One-click PDF download immediately after submission - One-click PDF download immediately after submission
- **Toast notifications**: success/error/warning feedback for form submissions, validation, and PDF downloads
### Employee Profile Modal ### Employee Profile Modal
- Full violation history with resolution status and **amendment count badge** per record - Full violation history with resolution status and **amendment count badge** per record
- **✎ Edit Employee** button — update name, department, supervisor, or notes inline - **✎ Edit Employee** button — update name, department, supervisor, or notes inline
- **Merge Duplicate** tab — reassign all violations from a duplicate record and delete it - **Merge Duplicate** tab — reassign all violations from a duplicate record and delete it
- **Amend** button per active violation — edit non-scoring fields (location, notes, witness, etc.) with a full field-level diff history - **Amend** button per active violation — edit non-scoring fields (location, notes, witness, acknowledgment, etc.) with a full field-level diff history
- Negate / restore individual violations (soft delete with resolution type + notes) - Negate / restore individual violations (soft delete with resolution type + notes)
- Hard delete option for data entry errors - Hard delete option for data entry errors
- PDF download for any historical violation record - PDF download for any historical violation record
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring - **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators - **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators
- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit
### Audit Log ### Audit Log
- Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted - Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted
@@ -160,6 +163,13 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
- Covers feature map, CPAS tier system, workflow guidance, and roadmap - Covers feature map, CPAS tier system, workflow guidance, and roadmap
- No external link required; always reflects current deployed version - No external link required; always reflects current deployed version
### Toast Notification System
- Global toast notifications for all user actions across the application
- Four variants: success (green), error (red), warning (gold), info (blue)
- Auto-dismiss with configurable duration and visual progress bar countdown
- Slide-in animation; stacks up to 5 notifications simultaneously
- Consistent dark theme styling matching the rest of the UI
### CPAS Tier System ### CPAS Tier System
| Points | Tier | Label | | Points | Tier | Label |
@@ -176,9 +186,11 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
### PDF Generation ### PDF Generation
- Puppeteer + system Chromium (bundled in Docker image) - Puppeteer + system Chromium (bundled in Docker image)
- Logo loaded from disk at startup (no hardcoded base64); falls back gracefully if not found
- Generated on-demand per violation via `GET /api/violations/:id/pdf` - Generated on-demand per violation via `GET /api/violations/:id/pdf`
- Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf` - Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf`
- PDF captures prior active points **at the time of the incident** (snapshot stored on insert) - PDF captures prior active points **at the time of the incident** (snapshot stored on insert)
- **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing
--- ---
@@ -195,7 +207,7 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
| GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining | | GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining |
| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) | | PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) |
| GET | `/api/dashboard` | All employees with active points + violation counts | | GET | `/api/dashboard` | All employees with active points + violation counts |
| POST | `/api/violations` | Log a new violation | | POST | `/api/violations` | Log a new violation (accepts `acknowledged_by`, `acknowledged_date`) |
| GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts | | GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts |
| PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) | | PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) |
| PATCH | `/api/violations/:id/restore` | Restore a negated violation | | PATCH | `/api/violations/:id/restore` | Restore a negated violation |
@@ -219,7 +231,8 @@ cpas/
│ ├── schema.sql # Tables + 90-day active score view │ ├── schema.sql # Tables + 90-day active score view
│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations │ └── database.js # SQLite connection (better-sqlite3) + auto-migrations
├── pdf/ ├── pdf/
── generator.js # Puppeteer PDF generation ── generator.js # Puppeteer PDF generation
│ └── template.js # HTML template (loads logo from disk, ack signature rendering)
└── client/ # React frontend (Vite) └── client/ # React frontend (Vite)
├── package.json ├── package.json
├── vite.config.js ├── vite.config.js
@@ -235,7 +248,7 @@ cpas/
├── CpasBadge.jsx # Tier badge + color logic ├── CpasBadge.jsx # Tier badge + color logic
├── TierWarning.jsx # Pre-submit tier crossing alert ├── TierWarning.jsx # Pre-submit tier crossing alert
├── Dashboard.jsx # Company-wide leaderboard + audit log trigger ├── Dashboard.jsx # Company-wide leaderboard + audit log trigger
├── ViolationForm.jsx # Violation entry form ├── ViolationForm.jsx # Violation entry form + ack signature fields
├── EmployeeModal.jsx # Employee profile + history modal ├── EmployeeModal.jsx # Employee profile + history modal
├── EditEmployeeModal.jsx # Employee edit + merge duplicate ├── EditEmployeeModal.jsx # Employee edit + merge duplicate
├── AmendViolationModal.jsx # Non-scoring field amendment + diff history ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history
@@ -244,6 +257,7 @@ cpas/
├── ViolationHistory.jsx # Violation list component ├── ViolationHistory.jsx # Violation list component
├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown
├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags
├── ToastProvider.jsx # Global toast notification system + useToast hook
└── ReadmeModal.jsx # In-app admin documentation panel └── ReadmeModal.jsx # In-app admin documentation panel
``` ```
@@ -254,7 +268,7 @@ cpas/
Six tables + one view: Six tables + one view:
- **`employees`** — id, name, department, supervisor, **notes** - **`employees`** — id, name, department, supervisor, **notes**
- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging - **`violations`** — full incident record including `prior_active_points` snapshot at time of logging, `acknowledged_by` and `acknowledged_date` for employee acknowledgment
- **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations) - **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations)
- **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment - **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment
- **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp) - **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp)
@@ -273,6 +287,8 @@ Point values, violation type, and incident date are **immutable** after submissi
| `details` | Narrative description | | `details` | Narrative description |
| `submitted_by` | Supervisor who submitted | | `submitted_by` | Supervisor who submitted |
| `witness_name` | Witness on record | | `witness_name` | Witness on record |
| `acknowledged_by` | Employee who acknowledged receipt |
| `acknowledged_date` | Date of employee acknowledgment |
--- ---
@@ -301,6 +317,8 @@ Point values, violation type, and incident date are **immutable** after submissi
| 6 | Employee notes / flags | Free-text notes on employee record with quick-add HR tags; does not affect scoring | | 6 | Employee notes / flags | Free-text notes on employee record with quick-add HR tags; does not affect scoring |
| 6 | Point expiration timeline | Per-violation roll-off countdown with tier-drop projections | | 6 | Point expiration timeline | Per-violation roll-off countdown with tier-drop projections |
| 6 | In-app documentation | Admin usage guide and feature map accessible from the navbar | | 6 | In-app documentation | Admin usage guide and feature map accessible from the navbar |
| 7 | Acknowledgment signature field | "Received by employee" name + date on the violation form; renders on the PDF replacing blank signature lines with recorded acknowledgment |
| 7 | Toast notification system | Global success/error/warning/info notifications for all user actions; auto-dismiss with progress bar; consistent dark theme |
--- ---
@@ -315,7 +333,6 @@ Point values, violation type, and incident date are **immutable** after submissi
- **Supervisor view** — scoped dashboard showing only the employees under a given supervisor, useful for multi-supervisor environments - **Supervisor view** — scoped dashboard showing only the employees under a given supervisor, useful for multi-supervisor environments
#### Violation Workflow #### Violation Workflow
- **Acknowledgment signature field** — a "received by employee" name/date field on the violation form that prints on the PDF, replacing the blank signature line
- **Draft / pending violations** — save a violation as draft before finalizing, useful when incidents need review before being officially logged - **Draft / pending violations** — save a violation as draft before finalizing, useful when incidents need review before being officially logged
- **Bulk violation import** — CSV import for migrating historical records from paper logs or a prior system - **Bulk violation import** — CSV import for migrating historical records from paper logs or a prior system
+3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import ViolationForm from './components/ViolationForm'; import ViolationForm from './components/ViolationForm';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import ReadmeModal from './components/ReadmeModal'; import ReadmeModal from './components/ReadmeModal';
import ToastProvider from './components/ToastProvider';
const tabs = [ const tabs = [
{ id: 'dashboard', label: '📊 Dashboard' }, { id: 'dashboard', label: '📊 Dashboard' },
@@ -45,6 +46,7 @@ export default function App() {
const [showReadme, setShowReadme] = useState(false); const [showReadme, setShowReadme] = useState(false);
return ( return (
<ToastProvider>
<div style={s.app}> <div style={s.app}>
<nav style={s.nav}> <nav style={s.nav}>
<div style={s.logoWrap}> <div style={s.logoWrap}>
@@ -69,5 +71,6 @@ export default function App() {
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />} {showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
</div> </div>
</ToastProvider>
); );
} }
+26 -3
View File
@@ -6,6 +6,7 @@ import EditEmployeeModal from './EditEmployeeModal';
import AmendViolationModal from './AmendViolationModal'; import AmendViolationModal from './AmendViolationModal';
import ExpirationTimeline from './ExpirationTimeline'; import ExpirationTimeline from './ExpirationTimeline';
import EmployeeNotes from './EmployeeNotes'; import EmployeeNotes from './EmployeeNotes';
import { useToast } from './ToastProvider';
const s = { const s = {
overlay: { overlay: {
@@ -97,6 +98,8 @@ export default function EmployeeModal({ employeeId, onClose }) {
const [editingEmp, setEditingEmp] = useState(false); const [editingEmp, setEditingEmp] = useState(false);
const [amending, setAmending] = useState(null); // violation object const [amending, setAmending] = useState(null); // violation object
const toast = useToast();
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
@@ -116,6 +119,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const handleDownloadPdf = async (violId, empName, date) => { const handleDownloadPdf = async (violId, empName, date) => {
try {
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' }); const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
const link = document.createElement('a'); const link = document.createElement('a');
@@ -125,25 +129,44 @@ export default function EmployeeModal({ employeeId, onClose }) {
link.click(); link.click();
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
toast.success('PDF downloaded.');
} catch (err) {
toast.error('PDF generation failed: ' + (err.response?.data?.error || err.message));
}
}; };
const handleHardDelete = async (id) => { const handleHardDelete = async (id) => {
try {
await axios.delete(`/api/violations/${id}`); await axios.delete(`/api/violations/${id}`);
toast.success('Violation permanently deleted.');
setConfirmDel(null); setConfirmDel(null);
load(); load();
} catch (err) {
toast.error('Delete failed: ' + (err.response?.data?.error || err.message));
}
}; };
const handleRestore = async (id) => { const handleRestore = async (id) => {
try {
await axios.patch(`/api/violations/${id}/restore`); await axios.patch(`/api/violations/${id}/restore`);
toast.success('Violation restored to active.');
setConfirmDel(null); setConfirmDel(null);
load(); load();
} catch (err) {
toast.error('Restore failed: ' + (err.response?.data?.error || err.message));
}
}; };
const handleNegate = async ({ resolution_type, details, resolved_by }) => { const handleNegate = async ({ resolution_type, details, resolved_by }) => {
try {
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
toast.success('Violation negated.');
setNegating(null); setNegating(null);
setConfirmDel(null); setConfirmDel(null);
load(); load();
} catch (err) {
toast.error('Negate failed: ' + (err.response?.data?.error || err.message));
}
}; };
const tier = score ? getTier(score.active_points) : null; const tier = score ? getTier(score.active_points) : null;
@@ -203,7 +226,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
</div> </div>
<div style={{ ...s.scoreCard, minWidth: '140px' }}> <div style={{ ...s.scoreCard, minWidth: '140px' }}>
<div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}> <div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}>
{tier ? tier.label : ''} {tier ? tier.label : ''}
</div> </div>
<div style={s.scoreLbl}>Current Tier</div> <div style={s.scoreLbl}>Current Tier</div>
</div> </div>
@@ -405,14 +428,14 @@ export default function EmployeeModal({ employeeId, onClose }) {
<EditEmployeeModal <EditEmployeeModal
employee={employee} employee={employee}
onClose={() => setEditingEmp(false)} onClose={() => setEditingEmp(false)}
onSaved={load} onSaved={() => { toast.success('Employee updated.'); load(); }}
/> />
)} )}
{amending && ( {amending && (
<AmendViolationModal <AmendViolationModal
violation={amending} violation={amending}
onClose={() => setAmending(null)} onClose={() => setAmending(null)}
onSaved={load} onSaved={() => { toast.success('Violation amended.'); load(); }}
/> />
)} )}
</div> </div>
+29 -10
View File
@@ -23,8 +23,8 @@ function mdToHtml(md) {
const hm = line.match(/^(#{1,4})\s+(.+)/); const hm = line.match(/^(#{1,4})\s+(.+)/);
if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; } if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; }
if (line.trim().startsWith('|')) { if (line.trim().startsWith('|')) {
const cells = line.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim()); const cells = line.trim().replace(/^\|||\|$/g,'').split('|').map(c=>c.trim());
if (!inTable) { close(); inTable=true; out.push('<table><thead><tr>'); cells.forEach(c=>out.push(`<th>${inline(c)}</th>`)); out.push('</tr></thead><tbody>'); i++; if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++; continue; } if (!inTable) { close(); inTable=true; out.push('<table><thead><tr>'); cells.forEach(c=>out.push(`<th>${inline(c)}</th>`)); out.push('</tr></thead><tbody>'); i++; if (i < lines.length && /^[\|\s\:\-]+$/.test(lines[i])) i++; continue; }
else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); i++; continue; } else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); i++; continue; }
} }
const ul = line.match(/^[-*]\s+(.*)/); const ul = line.match(/^[-*]\s+(.*)/);
@@ -46,7 +46,7 @@ function buildToc(md) {
}, []); }, []);
} }
// Styles // Styles
const S = { const S = {
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' }, overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' },
panel: { background:'#111217', color:'#f8f9fa', width:'760px', maxWidth:'95vw', height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', display:'flex', flexDirection:'column' }, panel: { background:'#111217', color:'#f8f9fa', width:'760px', maxWidth:'95vw', height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', display:'flex', flexDirection:'column' },
@@ -76,7 +76,7 @@ const CSS = `
.adm tr:hover td { background:#1e1f2e } .adm tr:hover td { background:#1e1f2e }
`; `;
// Admin guide content (no install / Docker content) // Admin guide content (no install / Docker content)
const GUIDE_MD = `# CPAS Tracker — Admin Guide const GUIDE_MD = `# CPAS Tracker — Admin Guide
Internal tool for CPAS violation documentation, workforce standing management, and audit compliance. All data is stored locally in the Docker container volume there is no external dependency. Internal tool for CPAS violation documentation, workforce standing management, and audit compliance. All data is stored locally in the Docker container volume there is no external dependency.
@@ -129,7 +129,9 @@ Use the **+ New Violation** tab.
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers the points slider jumps to the maximum allowed for that violation type. 4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers the points slider jumps to the maximum allowed for that violation type.
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting. 5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range). 6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
7. Submit. A **PDF download link** appears immediately download it for the employee's file. 7. **Employee Acknowledgment** (optional): if the employee is present and acknowledges receipt, enter their printed name and the acknowledgment date. This replaces the blank signature line on the PDF with a recorded acknowledgment and an "Acknowledged" badge. Leave blank if the employee is not present or declines.
8. Submit. A **PDF download link** appears immediately download it for the employee's file.
9. **Toast notifications** confirm success or surface errors at the top right of the screen. Toasts auto-dismiss after a few seconds.
--- ---
@@ -149,10 +151,12 @@ Visible when the employee has active points. Shows each active violation as a pr
#### Violation History #### Violation History
Full record of all submissions active, negated, and resolved. Full record of all submissions active, negated, and resolved.
- **Amend** edit non-scoring fields (location, details, witness, submitted-by, incident time) on any active violation. Every change is logged as a field-level diff (old new) with timestamp. Points, type, and incident date are immutable. - **Amend** edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old new) with timestamp. Points, type, and incident date are immutable.
- **Negate** soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**. - **Negate** soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
- **Hard delete** permanent removal. Use only for genuine data entry errors. - **Hard delete** permanent removal. Use only for genuine data entry errors.
- **PDF** download the formal violation document for any historical record. - **PDF** download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
All actions trigger **toast notifications** confirming success or surfacing errors.
#### Edit Employee #### Edit Employee
Update name, department, or supervisor. Changes are logged to the audit trail. Update name, department, or supervisor. Changes are logged to the audit trail.
@@ -178,7 +182,7 @@ The audit log is the authoritative record for compliance review. Nothing in it c
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot. Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
**Amendable fields:** incident time, location, details, submitted-by, witness name. **Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
**Immutable fields:** violation type, incident date, point value. **Immutable fields:** violation type, incident date, point value.
@@ -186,6 +190,19 @@ Each amendment stores a before/after diff for every changed field. Amendment his
--- ---
### Toast Notifications
All user actions across the application produce **toast notifications** small slide-in messages at the top right of the screen.
- **Success** (green) violation submitted, PDF downloaded, employee updated, etc.
- **Error** (red) API failures, validation errors, PDF generation issues
- **Warning** (gold) missing required fields, policy alerts
- **Info** (blue) general informational messages
Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has a progress bar countdown and a manual dismiss button. Up to 5 toasts can stack simultaneously.
---
## Immutability Rules Quick Reference ## Immutability Rules Quick Reference
| Action | Allowed? | Notes | | Action | Allowed? | Notes |
@@ -194,6 +211,7 @@ Each amendment stores a before/after diff for every changed field. Amendment his
| Edit incident date | No | Immutable after submission | | Edit incident date | No | Immutable after submission |
| Edit point value | No | Immutable after submission | | Edit point value | No | Immutable after submission |
| Edit location / details / witness | Yes | Via Amend | | Edit location / details / witness | Yes | Via Amend |
| Edit acknowledged-by / acknowledged-date | Yes | Via Amend |
| Negate (void) a violation | Yes | Soft delete; reversible | | Negate (void) a violation | Yes | Soft delete; reversible |
| Hard delete a violation | Yes | Permanent; use sparingly | | Hard delete a violation | Yes | Permanent; use sparingly |
| Edit employee name / dept / supervisor | Yes | Logged to audit trail | | Edit employee name / dept / supervisor | Yes | Logged to audit trail |
@@ -217,6 +235,8 @@ Each amendment stores a before/after diff for every changed field. Amendment his
- Employee notes and flags with quick-add HR tags - Employee notes and flags with quick-add HR tags
- Point expiration timeline with tier-drop projections - Point expiration timeline with tier-drop projections
- In-app admin guide (this panel) - In-app admin guide (this panel)
- Acknowledgment signature field employee name + date on form and PDF
- Toast notification system global feedback for all user actions
--- ---
@@ -224,7 +244,6 @@ Each amendment stores a before/after diff for every changed field. Amendment his
These are well-scoped additions that fit the current architecture without major changes. These are well-scoped additions that fit the current architecture without major changes.
- **Acknowledgment signature field** "received by employee" name + date on the violation form; prints on the PDF in place of the blank signature line. Addresses the most common field workflow gap.
- **CSV export** one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting. - **CSV export** one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
- **Supervisor-scoped view** filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth. - **Supervisor-scoped view** filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
@@ -253,7 +272,7 @@ These require meaningful infrastructure additions and should be evaluated agains
- **Dark/light theme toggle** UI is currently dark-only. - **Dark/light theme toggle** UI is currently dark-only.
`; `;
// Component // Component
export default function ReadmeModal({ onClose }) { export default function ReadmeModal({ onClose }) {
const bodyRef = useRef(null); const bodyRef = useRef(null);
const html = mdToHtml(GUIDE_MD); const html = mdToHtml(GUIDE_MD);
+145
View File
@@ -0,0 +1,145 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
const ToastContext = createContext(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
const VARIANTS = {
success: { bg: '#053321', border: '#0f5132', color: '#9ef7c1', icon: '✓' },
error: { bg: '#3c1114', border: '#f5c6cb', color: '#ffb3b8', icon: '✗' },
info: { bg: '#0c1f3f', border: '#2563eb', color: '#93c5fd', icon: '' },
warning: { bg: '#3b2e00', border: '#d4af37', color: '#ffdf8a', icon: '⚠' },
};
let nextId = 0;
function Toast({ toast, onDismiss }) {
const v = VARIANTS[toast.variant] || VARIANTS.info;
const [exiting, setExiting] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
setExiting(true);
setTimeout(() => onDismiss(toast.id), 280);
}, toast.duration || 4000);
return () => clearTimeout(timerRef.current);
}, [toast.id, toast.duration, onDismiss]);
const handleDismiss = () => {
clearTimeout(timerRef.current);
setExiting(true);
setTimeout(() => onDismiss(toast.id), 280);
};
return (
<div style={{
background: v.bg,
border: `1px solid ${v.border}`,
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
color: v.color,
fontSize: '13px',
fontWeight: 500,
minWidth: '320px',
maxWidth: '480px',
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
animation: exiting ? 'toastOut 0.28s ease-in forwards' : 'toastIn 0.28s ease-out',
position: 'relative',
overflow: 'hidden',
}}>
<span style={{ fontSize: '16px', lineHeight: 1, flexShrink: 0, marginTop: '1px' }}>{v.icon}</span>
<span style={{ flex: 1, lineHeight: 1.5 }}>{toast.message}</span>
<button
onClick={handleDismiss}
style={{
background: 'none', border: 'none', color: v.color, cursor: 'pointer',
fontSize: '16px', padding: '0 0 0 8px', opacity: 0.7, lineHeight: 1, flexShrink: 0,
}}
aria-label="Dismiss"
>
×
</button>
<div style={{
position: 'absolute', bottom: 0, left: 0, height: '3px',
background: v.color, opacity: 0.4, borderRadius: '0 0 8px 8px',
animation: `toastProgress ${toast.duration || 4000}ms linear forwards`,
}} />
</div>
);
}
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const dismiss = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const addToast = useCallback((message, variant = 'info', duration = 4000) => {
const id = ++nextId;
setToasts(prev => {
const next = [...prev, { id, message, variant, duration }];
return next.length > 5 ? next.slice(-5) : next;
});
return id;
}, []);
const toast = useCallback({
success: (msg, dur) => addToast(msg, 'success', dur),
error: (msg, dur) => addToast(msg, 'error', dur || 6000),
info: (msg, dur) => addToast(msg, 'info', dur),
warning: (msg, dur) => addToast(msg, 'warning', dur || 5000),
}, [addToast]);
// Inject keyframes once
useEffect(() => {
if (document.getElementById('toast-keyframes')) return;
const style = document.createElement('style');
style.id = 'toast-keyframes';
style.textContent = `
@keyframes toastIn {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
@keyframes toastProgress {
from { width: 100%; }
to { width: 0%; }
}
`;
document.head.appendChild(style);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<div style={{
position: 'fixed',
top: '16px',
right: '16px',
zIndex: 99999,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'none',
}}>
{toasts.map(t => (
<div key={t.id} style={{ pointerEvents: 'auto' }}>
<Toast toast={t} onDismiss={dismiss} />
</div>
))}
</div>
</ToastContext.Provider>
);
}
+37 -7
View File
@@ -5,6 +5,7 @@ import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence';
import CpasBadge from './CpasBadge'; import CpasBadge from './CpasBadge';
import TierWarning from './TierWarning'; import TierWarning from './TierWarning';
import ViolationHistory from './ViolationHistory'; import ViolationHistory from './ViolationHistory';
import { useToast } from './ToastProvider';
const s = { const s = {
content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' }, content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' },
@@ -26,14 +27,15 @@ const s = {
btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' }, btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' },
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' }, btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' },
note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' }, note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' },
statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' }, ackSection: { background: '#181924', borderLeft: '4px solid #2196F3', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
statusErr: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }, ackHint: { fontSize: '12px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
}; };
const EMPTY_FORM = { const EMPTY_FORM = {
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '', employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
violationType: '', incidentDate: '', incidentTime: '', violationType: '', incidentDate: '', incidentTime: '',
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1, amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
acknowledgedBy: '', acknowledgedDate: '',
}; };
export default function ViolationForm() { export default function ViolationForm() {
@@ -44,6 +46,7 @@ export default function ViolationForm() {
const [lastViolId, setLastViolId] = useState(null); const [lastViolId, setLastViolId] = useState(null);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const toast = useToast();
const intel = useEmployeeIntelligence(form.employeeId || null); const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => { useEffect(() => {
@@ -77,8 +80,8 @@ export default function ViolationForm() {
const handleSubmit = async e => { const handleSubmit = async e => {
e.preventDefault(); e.preventDefault();
if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' }); if (!form.violationType) { toast.warning('Please select a violation type.'); return; }
if (!form.employeeName) return setStatus({ ok: false, msg: 'Please enter an employee name.' }); if (!form.employeeName) { toast.warning('Please enter an employee name.'); return; }
try { try {
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
const employeeId = empRes.data.id; const employeeId = empRes.data.id;
@@ -93,6 +96,8 @@ export default function ViolationForm() {
location: form.location || null, location: form.location || null,
details: form.additionalDetails || null, details: form.additionalDetails || null,
witness_name: form.witnessName || null, witness_name: form.witnessName || null,
acknowledged_by: form.acknowledgedBy || null,
acknowledged_date: form.acknowledgedDate || null,
}); });
const newId = violRes.data.id; const newId = violRes.data.id;
@@ -101,11 +106,14 @@ export default function ViolationForm() {
const empList = await axios.get('/api/employees'); const empList = await axios.get('/api/employees');
setEmployees(empList.data); setEmployees(empList.data);
toast.success(`Violation #${newId} recorded — click Download PDF to save the document.`);
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` }); setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
setForm(EMPTY_FORM); setForm(EMPTY_FORM);
setViolation(null); setViolation(null);
} catch (err) { } catch (err) {
setStatus({ ok: false, msg: '✗ Error: ' + (err.response?.data?.error || err.message) }); const msg = err.response?.data?.error || err.message;
toast.error(`Failed to submit: ${msg}`);
setStatus({ ok: false, msg: '✗ Error: ' + msg });
} }
}; };
@@ -122,8 +130,9 @@ export default function ViolationForm() {
link.click(); link.click();
link.remove(); link.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
toast.success('PDF downloaded successfully.');
} catch (err) { } catch (err) {
setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message }); toast.error('PDF generation failed: ' + err.message);
} finally { } finally {
setPdfLoading(false); setPdfLoading(false);
} }
@@ -275,6 +284,27 @@ export default function ViolationForm() {
)} )}
</div> </div>
{/* Acknowledgment Signature Section */}
<div style={s.ackSection}>
<h2 style={{ ...s.sectionTitle, fontSize: '17px' }}>Employee Acknowledgment</h2>
<p style={{ fontSize: '12px', color: '#9ca0b8', marginBottom: '14px', lineHeight: 1.6 }}>
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
This replaces the blank signature line on the PDF with a recorded acknowledgment.
</p>
<div style={s.grid}>
<div style={s.item}>
<label style={s.label}>Acknowledged By (Employee Name):</label>
<input style={s.input} type="text" name="acknowledgedBy" value={form.acknowledgedBy} onChange={handleChange} placeholder="Employee's printed name" />
<div style={s.ackHint}>Leave blank if employee is not present or declines to sign</div>
</div>
<div style={s.item}>
<label style={s.label}>Acknowledgment Date:</label>
<input style={s.input} type="date" name="acknowledgedDate" value={form.acknowledgedDate} onChange={handleChange} />
<div style={s.ackHint}>Date the employee received and acknowledged this document</div>
</div>
</div>
</div>
<div style={s.btnRow}> <div style={s.btnRow}>
<button type="submit" style={s.btnPrimary}>Submit Violation</button> <button type="submit" style={s.btnPrimary}>Submit Violation</button>
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); setLastViolId(null); }}> <button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); setLastViolId(null); }}>
@@ -298,7 +328,7 @@ export default function ViolationForm() {
</div> </div>
)} )}
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>} {status && <div style={status.ok ? { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' } : { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }}>{status.msg}</div>}
</form> </form>
{form.employeeId && ( {form.employeeId && (
+2
View File
@@ -19,6 +19,8 @@ if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD C
if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); 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_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('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT");
if (!cols.includes('acknowledged_by')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_by TEXT");
if (!cols.includes('acknowledged_date')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_date TEXT");
// Employee notes column (free-text, does not affect scoring) // Employee notes column (free-text, does not affect scoring)
const empCols = db.prepare('PRAGMA table_info(employees)').all().map(c => c.name); const empCols = db.prepare('PRAGMA table_info(employees)').all().map(c => c.name);
+2
View File
@@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS violations (
negated_at DATETIME, negated_at DATETIME,
prior_active_points INTEGER, -- snapshot at time of logging prior_active_points INTEGER, -- snapshot at time of logging
prior_tier_label TEXT, -- optional human-readable tier prior_tier_label TEXT, -- optional human-readable tier
acknowledged_by TEXT, -- employee name who acknowledged receipt
acknowledged_date TEXT, -- date of acknowledgment (YYYY-MM-DD)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
+103 -271
View File
File diff suppressed because one or more lines are too long
+19 -16
View File
@@ -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);
@@ -134,7 +134,7 @@ 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. // 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) => {
@@ -190,7 +190,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 +216,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_date
} = req.body; } = req.body;
if (!employee_id || !violation_type || !points || !incident_date) { if (!employee_id || !violation_type || !points || !incident_date) {
@@ -231,14 +232,16 @@ 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,
prior_active_points prior_active_points,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) acknowledged_by, acknowledged_date
) 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,
priorPts priorPts,
acknowledged_by || null, acknowledged_date || null
); );
audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, { audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
@@ -248,9 +251,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_date'];
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 +298,7 @@ app.patch('/api/violations/:id/amend', (req, res) => {
res.json(updated); res.json(updated);
}); });
// ── Negate a violation ─────────────────────────────────────────────────────── // ── 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 +326,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 +340,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 +356,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 +375,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(`