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 committed 2026-03-07 21:47:24 -06:00
commit 9c4c357cbe
10 files changed
+431 -355

No files matched your search

+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
+24 -21
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,29 +46,31 @@ export default function App() {
const [showReadme, setShowReadme] = useState(false); const [showReadme, setShowReadme] = useState(false);
return ( return (
<div style={s.app}> <ToastProvider>
<nav style={s.nav}> <div style={s.app}>
<div style={s.logoWrap}> <nav style={s.nav}>
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} /> <div style={s.logoWrap}>
<div style={s.logoText}>CPAS Tracker</div> <img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
<div style={s.logoText}>CPAS Tracker</div>
</div>
{tabs.map(t => (
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
<button style={s.docsBtn} onClick={() => setShowReadme(true)} title="Open admin documentation">
<span>?</span> Docs
</button>
</nav>
<div style={s.card}>
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div> </div>
{tabs.map(t => ( {showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
<button style={s.docsBtn} onClick={() => setShowReadme(true)} title="Open admin documentation">
<span>?</span> Docs
</button>
</nav>
<div style={s.card}>
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div> </div>
</ToastProvider>
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
</div>
); );
} }
+45 -22
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,34 +119,54 @@ export default function EmployeeModal({ employeeId, onClose }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const handleDownloadPdf = async (violId, empName, date) => { const handleDownloadPdf = async (violId, empName, date) => {
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' }); try {
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
const link = document.createElement('a'); const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
link.href = url; const link = document.createElement('a');
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`; link.href = url;
document.body.appendChild(link); link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
link.click(); document.body.appendChild(link);
link.remove(); link.click();
window.URL.revokeObjectURL(url); link.remove();
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) => {
await axios.delete(`/api/violations/${id}`); try {
setConfirmDel(null); await axios.delete(`/api/violations/${id}`);
load(); toast.success('Violation permanently deleted.');
setConfirmDel(null);
load();
} catch (err) {
toast.error('Delete failed: ' + (err.response?.data?.error || err.message));
}
}; };
const handleRestore = async (id) => { const handleRestore = async (id) => {
await axios.patch(`/api/violations/${id}/restore`); try {
setConfirmDel(null); await axios.patch(`/api/violations/${id}/restore`);
load(); toast.success('Violation restored to active.');
setConfirmDel(null);
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 }) => {
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); try {
setNegating(null); await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
setConfirmDel(null); toast.success('Violation negated.');
load(); setNegating(null);
setConfirmDel(null);
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
@@ -1,14 +1,32 @@
// Inline base64 logo — avoids Puppeteer needing a live HTTP request to /static/ const fs = require('fs');
const LOGO_B64 = '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 Line truncated const path = require('path');
// Load logo from disk once at startup and convert to base64 data URI
// In Docker: /app/client/dist/static/mpm-logo.png
// In dev: ./client/public/static/mpm-logo.png (or dist after build)
let LOGO_DATA_URI = '';
const logoPaths = [
path.join(__dirname, '..', 'client', 'dist', 'static', 'mpm-logo.png'),
path.join(__dirname, '..', 'client', 'public', 'static', 'mpm-logo.png'),
];
for (const p of logoPaths) {
try {
const buf = fs.readFileSync(p);
LOGO_DATA_URI = `data:image/png;base64,${buf.toString('base64')}`;
console.log('[PDF] Logo loaded from', p);
break;
} catch (_) { /* try next path */ }
}
if (!LOGO_DATA_URI) console.warn('[PDF] Logo not found — PDF header will have no logo');
const TIERS = [ const TIERS = [
{ min: 0, max: 4, label: 'Tier 0–1 — Elite Standing', color: '#16a34a', bg: '#f0fdf4' }, { min: 0, max: 4, label: 'Tier 0\u20131 \u2014 Elite Standing', color: '#16a34a', bg: '#f0fdf4' },
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#854d0e', bg: '#fefce8' }, { min: 5, max: 9, label: 'Tier 1 \u2014 Realignment', color: '#854d0e', bg: '#fefce8' },
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#b45309', bg: '#fff7ed' }, { min: 10, max: 14, label: 'Tier 2 \u2014 Administrative Lockdown', color: '#b45309', bg: '#fff7ed' },
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#c2410c', bg: '#fff7ed' }, { min: 15, max: 19, label: 'Tier 3 \u2014 Verification', color: '#c2410c', bg: '#fff7ed' },
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' }, { min: 20, max: 24, label: 'Tier 4 \u2014 Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' },
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#991b1b', bg: '#fef2f2' }, { min: 25, max: 29, label: 'Tier 5 \u2014 Final Decision', color: '#991b1b', bg: '#fef2f2' },
{ min: 30, max: 999, label: 'Tier 6 — Separation', color: '#ffffff', bg: '#7f1d1d' }, { min: 30, max: 999, label: 'Tier 6 \u2014 Separation', color: '#ffffff', bg: '#7f1d1d' },
]; ];
function getTier(pts) { function getTier(pts) {
@@ -16,7 +34,7 @@ function getTier(pts) {
} }
function fmt(d) { function fmt(d) {
if (!d) return '—'; if (!d) return '\u2014';
return new Date(d + 'T12:00:00').toLocaleDateString('en-US', { return new Date(d + 'T12:00:00').toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
timeZone: 'America/Chicago', timeZone: 'America/Chicago',
@@ -25,15 +43,6 @@ function fmt(d) {
function fmtDT(d, t) { return t ? `${fmt(d)} at ${t}` : fmt(d); } function fmtDT(d, t) { return t ? `${fmt(d)} at ${t}` : fmt(d); }
function field(label, value) {
if (!value) return '';
return `
<div class="field">
<div class="field-label">${label}</div>
<div class="field-value">${value}</div>
</div>`;
}
function buildHtml(v, score) { function buildHtml(v, score) {
const priorPts = score.active_points || 0; const priorPts = score.active_points || 0;
const priorTier = getTier(priorPts); const priorTier = getTier(priorPts);
@@ -45,6 +54,15 @@ function buildHtml(v, score) {
}); });
const docId = `CPAS-${v.id.toString().padStart(5, '0')}`; const docId = `CPAS-${v.id.toString().padStart(5, '0')}`;
// Acknowledgment: if acknowledged_by is set, show filled data instead of blank sig line
const hasAck = !!v.acknowledged_by;
const ackName = v.acknowledged_by || '';
const ackDate = v.acknowledged_date ? fmt(v.acknowledged_date) : '';
const logoTag = LOGO_DATA_URI
? `<img src="${LOGO_DATA_URI}" class="logo" />`
: '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -60,7 +78,6 @@ function buildHtml(v, score) {
line-height: 1.5; line-height: 1.5;
} }
/* ── HEADER ── */
.header { .header {
background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 60%, #16213e 100%); background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 60%, #16213e 100%);
padding: 24px 36px; padding: 24px 36px;
@@ -71,278 +88,89 @@ function buildHtml(v, score) {
} }
.header-left { display: flex; align-items: center; gap: 16px; } .header-left { display: flex; align-items: center; gap: 16px; }
.logo { height: 36px; } .logo { height: 36px; }
.header-title { .header-title { font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: 0.3px; }
font-size: 18px; .header-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; letter-spacing: 0.5px; text-transform: uppercase; }
font-weight: 700;
color: #ffffff;
letter-spacing: 0.3px;
}
.header-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 3px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.header-right { text-align: right; } .header-right { text-align: right; }
.doc-id { .doc-id { font-size: 13px; font-weight: 700; color: #d4af37; letter-spacing: 0.5px; }
font-size: 13px; .doc-meta { font-size: 10px; color: #64748b; margin-top: 4px; }
font-weight: 700;
color: #d4af37;
letter-spacing: 0.5px;
}
.doc-meta {
font-size: 10px;
color: #64748b;
margin-top: 4px;
}
/* ── CONFIDENTIAL BANNER ── */
.confidential-bar { .confidential-bar {
background: #fef2f2; background: #fef2f2; border-bottom: 1px solid #fecaca;
border-bottom: 1px solid #fecaca; padding: 7px 36px; font-size: 11px; font-weight: 700; color: #991b1b;
padding: 7px 36px; letter-spacing: 0.8px; text-transform: uppercase; text-align: center;
font-size: 11px;
font-weight: 700;
color: #991b1b;
letter-spacing: 0.8px;
text-transform: uppercase;
text-align: center;
} }
/* ── BODY WRAPPER ── */
.body { padding: 28px 36px; } .body { padding: 28px 36px; }
/* ── SECTION ── */
.section { margin-bottom: 24px; } .section { margin-bottom: 24px; }
.section-header { .section-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
display: flex; .section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #64748b; }
align-items: center; .section-rule { flex: 1; height: 1px; background: #e2e8f0; }
gap: 10px;
margin-bottom: 12px;
}
.section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #64748b;
}
.section-rule {
flex: 1;
height: 1px;
background: #e2e8f0;
}
/* ── FIELD GRID ── */ .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; }
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 32px;
}
.field-grid.single { grid-template-columns: 1fr; } .field-grid.single { grid-template-columns: 1fr; }
.field { padding: 0; } .field { padding: 0; }
.field-label { .field-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 2px; }
font-size: 10px; .field-value { font-size: 13px; color: #1e293b; font-weight: 500; }
font-weight: 700; .field-value.prominent { font-size: 15px; font-weight: 700; color: #0f172a; }
text-transform: uppercase;
letter-spacing: 0.8px;
color: #94a3b8;
margin-bottom: 2px;
}
.field-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
.field-value.prominent {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
/* ── DETAIL BOX ── */
.detail-box { .detail-box {
background: #f8fafc; background: #f8fafc; border: 1px solid #e2e8f0; border-left: 4px solid #667eea;
border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px 16px; margin-top: 12px; font-size: 12px; color: #374151; line-height: 1.6;
border-left: 4px solid #667eea;
border-radius: 6px;
padding: 14px 16px;
margin-top: 12px;
font-size: 12px;
color: #374151;
line-height: 1.6;
}
.detail-box-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #94a3b8;
margin-bottom: 6px;
} }
.detail-box-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 6px; }
/* ── SCORE CARD ── */
.score-card { .score-card {
display: flex; display: flex; align-items: center; gap: 0; background: #f8fafc;
align-items: center; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; margin-top: 4px;
gap: 0;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
margin-top: 4px;
}
.score-cell {
flex: 1;
padding: 18px 16px;
text-align: center;
border-right: 1px solid #e2e8f0;
} }
.score-cell { flex: 1; padding: 18px 16px; text-align: center; border-right: 1px solid #e2e8f0; }
.score-cell:last-child { border-right: none; } .score-cell:last-child { border-right: none; }
.score-cell.operator { .score-cell.operator { flex: 0 0 48px; font-size: 24px; font-weight: 200; color: #cbd5e1; }
flex: 0 0 48px; .score-num { font-size: 32px; font-weight: 800; line-height: 1; }
font-size: 24px; .score-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-top: 4px; }
font-weight: 200; .tier-badge { display: inline-block; margin-top: 8px; padding: 3px 10px; border-radius: 12px; font-size: 10px; font-weight: 700; letter-spacing: 0.3px; }
color: #cbd5e1;
}
.score-num {
font-size: 32px;
font-weight: 800;
line-height: 1;
}
.score-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #94a3b8;
margin-top: 4px;
}
.tier-badge {
display: inline-block;
margin-top: 8px;
padding: 3px 10px;
border-radius: 12px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
}
/* ── POINTS PILL ── */
.points-pill { .points-pill {
display: inline-flex; display: inline-flex; align-items: center; gap: 10px;
align-items: center; background: #fffbeb; border: 2px solid #d4af37; border-radius: 8px;
gap: 10px; padding: 12px 24px; margin-bottom: 16px;
background: #fffbeb;
border: 2px solid #d4af37;
border-radius: 8px;
padding: 12px 24px;
margin-bottom: 16px;
}
.points-pill-num {
font-size: 42px;
font-weight: 900;
color: #d4af37;
line-height: 1;
}
.points-pill-label {
font-size: 12px;
color: #92400e;
line-height: 1.4;
} }
.points-pill-num { font-size: 42px; font-weight: 900; color: #d4af37; line-height: 1; }
.points-pill-label { font-size: 12px; color: #92400e; line-height: 1.4; }
.points-pill-label strong { display: block; font-size: 14px; } .points-pill-label strong { display: block; font-size: 14px; }
/* ── ESCALATION ALERT ── */
.escalation-alert { .escalation-alert {
background: #fef9c3; background: #fef9c3; border: 1.5px solid #eab308; border-radius: 8px;
border: 1.5px solid #eab308; padding: 12px 16px; margin-top: 14px; font-size: 12px; color: #713f12;
border-radius: 8px; display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
margin-top: 14px;
font-size: 12px;
color: #713f12;
display: flex;
align-items: center;
gap: 10px;
} }
.escalation-icon { font-size: 18px; } .escalation-icon { font-size: 18px; }
/* ── TIER TABLE ── */
.tier-table { width: 100%; border-collapse: collapse; } .tier-table { width: 100%; border-collapse: collapse; }
.tier-table th { .tier-table th { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; text-align: left; padding: 6px 12px; border-bottom: 2px solid #e2e8f0; }
font-size: 10px; .tier-table td { padding: 7px 12px; font-size: 12px; border-bottom: 1px solid #f1f5f9; }
text-transform: uppercase; .tier-table tr.current-tier td { background: #fffbeb; font-weight: 700; }
letter-spacing: 0.8px; .tier-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
color: #94a3b8;
text-align: left;
padding: 6px 12px;
border-bottom: 2px solid #e2e8f0;
}
.tier-table td {
padding: 7px 12px;
font-size: 12px;
border-bottom: 1px solid #f1f5f9;
}
.tier-table tr.current-tier td {
background: #fffbeb;
font-weight: 700;
}
.tier-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
/* ── NOTICE ── */
.notice { .notice {
background: #eff6ff; background: #eff6ff; border-left: 4px solid #3b82f6; border-radius: 0 6px 6px 0;
border-left: 4px solid #3b82f6; padding: 12px 16px; font-size: 11.5px; color: #1e40af; line-height: 1.6;
border-radius: 0 6px 6px 0;
padding: 12px 16px;
font-size: 11.5px;
color: #1e40af;
line-height: 1.6;
} }
/* ── SIGNATURE ── */ .sig-intro { font-size: 11.5px; color: #475569; line-height: 1.7; margin-bottom: 28px; }
.sig-intro {
font-size: 11.5px;
color: #475569;
line-height: 1.7;
margin-bottom: 28px;
}
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; } .sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
.sig-block { } .sig-line { border-bottom: 1.5px solid #334155; margin-bottom: 8px; min-height: 52px; }
.sig-line { .sig-line-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #64748b; }
border-bottom: 1.5px solid #334155; .sig-date-line { border-bottom: 1.5px solid #334155; margin-bottom: 8px; margin-top: 20px; min-height: 36px; }
margin-bottom: 8px;
min-height: 52px; .sig-filled { font-size: 14px; font-weight: 600; color: #1e293b; padding-bottom: 6px; border-bottom: 1.5px solid #334155; margin-bottom: 8px; min-height: 52px; display: flex; align-items: flex-end; }
} .sig-date-filled { font-size: 13px; color: #1e293b; padding-bottom: 6px; border-bottom: 1.5px solid #334155; margin-bottom: 8px; margin-top: 20px; min-height: 36px; display: flex; align-items: flex-end; }
.sig-line-label { .ack-badge { display: inline-block; background: #dcfce7; color: #166534; border: 1px solid #86efac; border-radius: 6px; padding: 2px 8px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; margin-left: 10px; }
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #64748b;
}
.sig-date-line {
border-bottom: 1.5px solid #334155;
margin-bottom: 8px;
margin-top: 20px;
min-height: 36px;
}
/* ── FOOTER BAR ── */
.footer-bar { .footer-bar {
margin-top: 32px; margin-top: 32px; padding: 10px 0 0; border-top: 1px solid #e2e8f0;
padding: 10px 0 0; font-size: 10px; color: #94a3b8; display: flex; justify-content: space-between;
border-top: 1px solid #e2e8f0;
font-size: 10px;
color: #94a3b8;
display: flex;
justify-content: space-between;
} }
</style> </style>
</head> </head>
@@ -350,7 +178,7 @@ function buildHtml(v, score) {
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<img src="data:image/png;base64,${LOGO_B64}" class="logo" /> ${logoTag}
<div> <div>
<div class="header-title">CPAS Violation Record</div> <div class="header-title">CPAS Violation Record</div>
<div class="header-sub">Comprehensive Professional Accountability System</div> <div class="header-sub">Comprehensive Professional Accountability System</div>
@@ -362,7 +190,7 @@ function buildHtml(v, score) {
</div> </div>
</div> </div>
<div class="confidential-bar">⚑ Confidential — Authorized HR &amp; Management Use Only</div> <div class="confidential-bar">\u26D1 Confidential \u2014 Authorized HR &amp; Management Use Only</div>
<div class="body"> <div class="body">
@@ -379,15 +207,15 @@ function buildHtml(v, score) {
</div> </div>
<div class="field"> <div class="field">
<div class="field-label">Department</div> <div class="field-label">Department</div>
<div class="field-value">${v.department || '—'}</div> <div class="field-value">${v.department || '\u2014'}</div>
</div> </div>
<div class="field"> <div class="field">
<div class="field-label">Supervisor</div> <div class="field-label">Supervisor</div>
<div class="field-value">${v.supervisor || '—'}</div> <div class="field-value">${v.supervisor || '\u2014'}</div>
</div> </div>
<div class="field"> <div class="field">
<div class="field-label">Witness / Documenting Officer</div> <div class="field-label">Witness / Documenting Officer</div>
<div class="field-value">${v.witness_name || '—'}</div> <div class="field-value">${v.witness_name || '\u2014'}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -468,7 +296,7 @@ function buildHtml(v, score) {
${escalated ? ` ${escalated ? `
<div class="escalation-alert"> <div class="escalation-alert">
<span class="escalation-icon">⚠</span> <span class="escalation-icon">\u26A0</span>
<div> <div>
<strong>Tier Escalation:</strong> <strong>Tier Escalation:</strong>
This violation advances the employee from <strong>${priorTier.label}</strong> This violation advances the employee from <strong>${priorTier.label}</strong>
@@ -493,13 +321,13 @@ function buildHtml(v, score) {
<tbody> <tbody>
${TIERS.map(t => { ${TIERS.map(t => {
const active = newTotal >= t.min && newTotal <= t.max; const active = newTotal >= t.min && newTotal <= t.max;
const range = t.min === 30 ? '30+' : `${t.min}–${t.max}`; const range = t.min === 30 ? '30+' : `${t.min}\u2013${t.max}`;
return `<tr class="${active ? 'current-tier' : ''}"> return `<tr class="${active ? 'current-tier' : ''}">
<td>${active ? '▶ ' : ''}${range}</td> <td>${active ? '\u25B6 ' : ''}${range}</td>
<td> <td>
<span class="tier-dot" style="background:${t.color === '#ffffff' ? t.bg : t.color};"></span> <span class="tier-dot" style="background:${t.color === '#ffffff' ? t.bg : t.color};"></span>
${t.label} ${t.label}
${active ? '<strong> ← Current</strong>' : ''} ${active ? '<strong> \u2190 Current</strong>' : ''}
</td> </td>
</tr>`; </tr>`;
}).join('')} }).join('')}
@@ -517,7 +345,7 @@ function buildHtml(v, score) {
<!-- Signatures --> <!-- Signatures -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Acknowledgement &amp; Signatures</div> <div class="section-title">Acknowledgement &amp; Signatures${hasAck ? '<span class="ack-badge">Acknowledged</span>' : ''}</div>
<div class="section-rule"></div> <div class="section-rule"></div>
</div> </div>
<p class="sig-intro"> <p class="sig-intro">
@@ -526,9 +354,13 @@ function buildHtml(v, score) {
</p> </p>
<div class="sig-grid"> <div class="sig-grid">
<div class="sig-block"> <div class="sig-block">
<div class="sig-line"></div> ${hasAck
? `<div class="sig-filled">${ackName}</div>`
: '<div class="sig-line"></div>'}
<div class="sig-line-label">Employee Signature</div> <div class="sig-line-label">Employee Signature</div>
<div class="sig-date-line"></div> ${hasAck && ackDate
? `<div class="sig-date-filled">${ackDate}</div>`
: '<div class="sig-date-line"></div>'}
<div class="sig-line-label">Date</div> <div class="sig-line-label">Date</div>
</div> </div>
<div class="sig-block"> <div class="sig-block">
@@ -541,8 +373,8 @@ function buildHtml(v, score) {
</div> </div>
<div class="footer-bar"> <div class="footer-bar">
<span>${docId} &nbsp;·&nbsp; ${v.employee_name} &nbsp;·&nbsp; Incident: ${v.incident_date}</span> <span>${docId} &nbsp;\u00B7&nbsp; ${v.employee_name} &nbsp;\u00B7&nbsp; Incident: ${v.incident_date}</span>
<span>Message Point Media — Internal Use Only</span> <span>Message Point Media \u2014 Internal Use Only</span>
</div> </div>
</div> </div>
+21 -18
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) => {
@@ -151,7 +151,7 @@ app.get('/api/employees/:id/expiration', (req, res) => {
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
@@ -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(`
@@ -399,7 +402,7 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
res.set({ res.set({
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`, 'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`,
'Content-Length': pdfBuffer.length, 'Content-Length': pdfBuffer.length,
}); });
res.end(pdfBuffer); res.end(pdfBuffer);
} catch (err) { } catch (err) {