diff --git a/README.md b/README.md index fa09458..9e3f23f 100755 --- a/README.md +++ b/README.md @@ -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 - **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 +- **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 +- **Toast notifications**: success/error/warning feedback for form submissions, validation, and PDF downloads ### Employee Profile Modal - Full violation history with resolution status and **amendment count badge** per record - **✎ Edit Employee** button — update name, department, supervisor, or notes inline - **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) - Hard delete option for data entry errors - 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 - **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 - 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 - 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 | Points | Tier | Label | @@ -176,9 +186,11 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude ### PDF Generation - 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` - Filename: `CPAS__.pdf` - 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 | | PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) | | 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 | | PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) | | PATCH | `/api/violations/:id/restore` | Restore a negated violation | @@ -219,7 +231,8 @@ cpas/ │ ├── schema.sql # Tables + 90-day active score view │ └── database.js # SQLite connection (better-sqlite3) + auto-migrations ├── 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) ├── package.json ├── vite.config.js @@ -235,7 +248,7 @@ cpas/ ├── CpasBadge.jsx # Tier badge + color logic ├── TierWarning.jsx # Pre-submit tier crossing alert ├── 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 ├── EditEmployeeModal.jsx # Employee edit + merge duplicate ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history @@ -244,6 +257,7 @@ cpas/ ├── ViolationHistory.jsx # Violation list component ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown ├── 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 ``` @@ -254,7 +268,7 @@ cpas/ Six tables + one view: - **`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_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) @@ -273,6 +287,8 @@ Point values, violation type, and incident date are **immutable** after submissi | `details` | Narrative description | | `submitted_by` | Supervisor who submitted | | `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 | 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 | +| 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 #### 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 - **Bulk violation import** — CSV import for migrating historical records from paper logs or a prior system diff --git a/client/src/App.jsx b/client/src/App.jsx index 8639fb6..b3645c2 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import ViolationForm from './components/ViolationForm'; import Dashboard from './components/Dashboard'; import ReadmeModal from './components/ReadmeModal'; +import ToastProvider from './components/ToastProvider'; const tabs = [ { id: 'dashboard', label: '📊 Dashboard' }, @@ -45,29 +46,31 @@ export default function App() { const [showReadme, setShowReadme] = useState(false); return ( -
- - -
- {tab === 'dashboard' ? : } + {showReadme && setShowReadme(false)} />}
- - {showReadme && setShowReadme(false)} />} -
+ ); } diff --git a/client/src/components/EmployeeModal.jsx b/client/src/components/EmployeeModal.jsx index d1a57f7..e5d9d4f 100755 --- a/client/src/components/EmployeeModal.jsx +++ b/client/src/components/EmployeeModal.jsx @@ -6,6 +6,7 @@ import EditEmployeeModal from './EditEmployeeModal'; import AmendViolationModal from './AmendViolationModal'; import ExpirationTimeline from './ExpirationTimeline'; import EmployeeNotes from './EmployeeNotes'; +import { useToast } from './ToastProvider'; const s = { overlay: { @@ -97,6 +98,8 @@ export default function EmployeeModal({ employeeId, onClose }) { const [editingEmp, setEditingEmp] = useState(false); const [amending, setAmending] = useState(null); // violation object + const toast = useToast(); + const load = useCallback(() => { setLoading(true); Promise.all([ @@ -116,34 +119,54 @@ export default function EmployeeModal({ employeeId, onClose }) { useEffect(() => { load(); }, [load]); const handleDownloadPdf = async (violId, empName, date) => { - const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' }); - const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); - const link = document.createElement('a'); - link.href = url; - link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); + try { + const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' }); + const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`; + document.body.appendChild(link); + link.click(); + 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) => { - await axios.delete(`/api/violations/${id}`); - setConfirmDel(null); - load(); + try { + await axios.delete(`/api/violations/${id}`); + toast.success('Violation permanently deleted.'); + setConfirmDel(null); + load(); + } catch (err) { + toast.error('Delete failed: ' + (err.response?.data?.error || err.message)); + } }; const handleRestore = async (id) => { - await axios.patch(`/api/violations/${id}/restore`); - setConfirmDel(null); - load(); + try { + await axios.patch(`/api/violations/${id}/restore`); + 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 }) => { - await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); - setNegating(null); - setConfirmDel(null); - load(); + try { + await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); + toast.success('Violation negated.'); + 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; @@ -203,7 +226,7 @@ export default function EmployeeModal({ employeeId, onClose }) {
- {tier ? tier.label : '–'} + {tier ? tier.label : '—'}
Current Tier
@@ -405,14 +428,14 @@ export default function EmployeeModal({ employeeId, onClose }) { setEditingEmp(false)} - onSaved={load} + onSaved={() => { toast.success('Employee updated.'); load(); }} /> )} {amending && ( setAmending(null)} - onSaved={load} + onSaved={() => { toast.success('Violation amended.'); load(); }} /> )} diff --git a/client/src/components/ReadmeModal.jsx b/client/src/components/ReadmeModal.jsx index 8819b89..fee0a27 100644 --- a/client/src/components/ReadmeModal.jsx +++ b/client/src/components/ReadmeModal.jsx @@ -23,8 +23,8 @@ function mdToHtml(md) { 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(`${inline(hm[2])}`); i++; continue; } if (line.trim().startsWith('|')) { - const cells = line.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim()); - if (!inTable) { close(); inTable=true; out.push(''); cells.forEach(c=>out.push(``)); out.push(''); i++; if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++; continue; } + const cells = line.trim().replace(/^\|||\|$/g,'').split('|').map(c=>c.trim()); + if (!inTable) { close(); inTable=true; out.push('
${inline(c)}
'); cells.forEach(c=>out.push(``)); out.push(''); i++; if (i < lines.length && /^[\|\s\:\-]+$/.test(lines[i])) i++; continue; } else { out.push(''); cells.forEach(c=>out.push(``)); out.push(''); i++; continue; } } const ul = line.match(/^[-*]\s+(.*)/); @@ -46,7 +46,7 @@ function buildToc(md) { }, []); } -// ─── Styles ─────────────────────────────────────────────────────────────────── +// ——— Styles —————————————————————————————————————————————————————————————————— const S = { 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' }, @@ -76,7 +76,7 @@ const CSS = ` .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 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. 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). -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 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**. - **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 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. -**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. @@ -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 | 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 point value | No | Immutable after submission | | Edit location / details / witness | Yes | Via Amend | +| Edit acknowledged-by / acknowledged-date | Yes | Via Amend | | Negate (void) a violation | Yes | Soft delete; reversible | | Hard delete a violation | Yes | Permanent; use sparingly | | 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 - Point expiration timeline with tier-drop projections - 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. -- **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. - **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. `; -// ─── Component ──────────────────────────────────────────────────────────────── +// ——— Component —————————————————————————————————————————————————————————————— export default function ReadmeModal({ onClose }) { const bodyRef = useRef(null); const html = mdToHtml(GUIDE_MD); diff --git a/client/src/components/ToastProvider.jsx b/client/src/components/ToastProvider.jsx new file mode 100644 index 0000000..193ff47 --- /dev/null +++ b/client/src/components/ToastProvider.jsx @@ -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 ( +
+ {v.icon} + {toast.message} + +
+
+ ); +} + +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 ( + + {children} +
+ {toasts.map(t => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx index ad6d329..3367803 100755 --- a/client/src/components/ViolationForm.jsx +++ b/client/src/components/ViolationForm.jsx @@ -5,6 +5,7 @@ import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence'; import CpasBadge from './CpasBadge'; import TierWarning from './TierWarning'; import ViolationHistory from './ViolationHistory'; +import { useToast } from './ToastProvider'; const s = { 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' }, 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' }, - statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' }, - statusErr: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }, + ackSection: { background: '#181924', borderLeft: '4px solid #2196F3', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' }, + ackHint: { fontSize: '12px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' }, }; const EMPTY_FORM = { employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '', violationType: '', incidentDate: '', incidentTime: '', amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1, + acknowledgedBy: '', acknowledgedDate: '', }; export default function ViolationForm() { @@ -44,6 +46,7 @@ export default function ViolationForm() { const [lastViolId, setLastViolId] = useState(null); const [pdfLoading, setPdfLoading] = useState(false); + const toast = useToast(); const intel = useEmployeeIntelligence(form.employeeId || null); useEffect(() => { @@ -77,8 +80,8 @@ export default function ViolationForm() { const handleSubmit = async e => { e.preventDefault(); - if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' }); - if (!form.employeeName) return setStatus({ ok: false, msg: 'Please enter an employee name.' }); + if (!form.violationType) { toast.warning('Please select a violation type.'); return; } + if (!form.employeeName) { toast.warning('Please enter an employee name.'); return; } try { const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); const employeeId = empRes.data.id; @@ -93,6 +96,8 @@ export default function ViolationForm() { location: form.location || null, details: form.additionalDetails || null, witness_name: form.witnessName || null, + acknowledged_by: form.acknowledgedBy || null, + acknowledged_date: form.acknowledgedDate || null, }); const newId = violRes.data.id; @@ -101,11 +106,14 @@ export default function ViolationForm() { const empList = await axios.get('/api/employees'); 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.` }); setForm(EMPTY_FORM); setViolation(null); } 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.remove(); window.URL.revokeObjectURL(url); + toast.success('PDF downloaded successfully.'); } catch (err) { - setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message }); + toast.error('PDF generation failed: ' + err.message); } finally { setPdfLoading(false); } @@ -275,6 +284,27 @@ export default function ViolationForm() { )}
+ {/* Acknowledgment Signature Section */} +
+

Employee Acknowledgment

+

+ 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. +

+
+
+ + +
Leave blank if employee is not present or declines to sign
+
+
+ + +
Date the employee received and acknowledged this document
+
+
+
+
)} - {status &&
{status.msg}
} + {status &&
{status.msg}
} {form.employeeId && ( diff --git a/db/database.js b/db/database.js index d4fe68b..8986967 100755 --- a/db/database.js +++ b/db/database.js @@ -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('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('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) const empCols = db.prepare('PRAGMA table_info(employees)').all().map(c => c.name); diff --git a/db/schema.sql b/db/schema.sql index 3bcc928..8129bb3 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS violations ( negated_at DATETIME, prior_active_points INTEGER, -- snapshot at time of logging 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 ); diff --git a/pdf/template.js b/pdf/template.js index c66fb6d..3a10deb 100755 --- a/pdf/template.js +++ b/pdf/template.js @@ -1,14 +1,32 @@ -// Inline base64 logo — avoids Puppeteer needing a live HTTP request to /static/ -const LOGO_B64 = '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'; +const fs = require('fs'); +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 = [ - { min: 0, max: 4, label: 'Tier 0–1 — Elite Standing', color: '#16a34a', bg: '#f0fdf4' }, - { min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#854d0e', bg: '#fefce8' }, - { min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#b45309', bg: '#fff7ed' }, - { min: 15, max: 19, label: 'Tier 3 — Verification', color: '#c2410c', bg: '#fff7ed' }, - { min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' }, - { min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#991b1b', bg: '#fef2f2' }, - { min: 30, max: 999, label: 'Tier 6 — Separation', color: '#ffffff', bg: '#7f1d1d' }, + { min: 0, max: 4, label: 'Tier 0\u20131 \u2014 Elite Standing', color: '#16a34a', bg: '#f0fdf4' }, + { min: 5, max: 9, label: 'Tier 1 \u2014 Realignment', color: '#854d0e', bg: '#fefce8' }, + { min: 10, max: 14, label: 'Tier 2 \u2014 Administrative Lockdown', color: '#b45309', bg: '#fff7ed' }, + { min: 15, max: 19, label: 'Tier 3 \u2014 Verification', color: '#c2410c', bg: '#fff7ed' }, + { min: 20, max: 24, label: 'Tier 4 \u2014 Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' }, + { min: 25, max: 29, label: 'Tier 5 \u2014 Final Decision', color: '#991b1b', bg: '#fef2f2' }, + { min: 30, max: 999, label: 'Tier 6 \u2014 Separation', color: '#ffffff', bg: '#7f1d1d' }, ]; function getTier(pts) { @@ -16,7 +34,7 @@ function getTier(pts) { } function fmt(d) { - if (!d) return '—'; + if (!d) return '\u2014'; return new Date(d + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago', @@ -25,15 +43,6 @@ function fmt(d) { function fmtDT(d, t) { return t ? `${fmt(d)} at ${t}` : fmt(d); } -function field(label, value) { - if (!value) return ''; - return ` -
-
${label}
-
${value}
-
`; -} - function buildHtml(v, score) { const priorPts = score.active_points || 0; const priorTier = getTier(priorPts); @@ -45,6 +54,15 @@ function buildHtml(v, score) { }); 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 + ? `` + : ''; + return ` @@ -60,7 +78,6 @@ function buildHtml(v, score) { line-height: 1.5; } - /* ── HEADER ── */ .header { background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 60%, #16213e 100%); padding: 24px 36px; @@ -71,278 +88,89 @@ function buildHtml(v, score) { } .header-left { display: flex; align-items: center; gap: 16px; } .logo { height: 36px; } - .header-title { - font-size: 18px; - 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-title { font-size: 18px; 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; } - .doc-id { - font-size: 13px; - font-weight: 700; - color: #d4af37; - letter-spacing: 0.5px; - } - .doc-meta { - font-size: 10px; - color: #64748b; - margin-top: 4px; - } + .doc-id { font-size: 13px; font-weight: 700; color: #d4af37; letter-spacing: 0.5px; } + .doc-meta { font-size: 10px; color: #64748b; margin-top: 4px; } - /* ── CONFIDENTIAL BANNER ── */ .confidential-bar { - background: #fef2f2; - border-bottom: 1px solid #fecaca; - padding: 7px 36px; - font-size: 11px; - font-weight: 700; - color: #991b1b; - letter-spacing: 0.8px; - text-transform: uppercase; - text-align: center; + background: #fef2f2; border-bottom: 1px solid #fecaca; + padding: 7px 36px; font-size: 11px; font-weight: 700; color: #991b1b; + letter-spacing: 0.8px; text-transform: uppercase; text-align: center; } - /* ── BODY WRAPPER ── */ .body { padding: 28px 36px; } - /* ── SECTION ── */ .section { margin-bottom: 24px; } - .section-header { - display: flex; - align-items: center; - 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; - } + .section-header { display: flex; align-items: center; 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 { padding: 0; } - .field-label { - font-size: 10px; - font-weight: 700; - 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; - } + .field-label { font-size: 10px; font-weight: 700; 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 { - background: #f8fafc; - border: 1px solid #e2e8f0; - 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; + background: #f8fafc; border: 1px solid #e2e8f0; 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; } - /* ── SCORE CARD ── */ .score-card { - display: flex; - align-items: center; - 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; + display: flex; align-items: center; 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:last-child { border-right: none; } - .score-cell.operator { - flex: 0 0 48px; - font-size: 24px; - font-weight: 200; - 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; - } + .score-cell.operator { flex: 0 0 48px; font-size: 24px; font-weight: 200; 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 { - display: inline-flex; - align-items: center; - gap: 10px; - 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; + display: inline-flex; align-items: center; gap: 10px; + 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-label strong { display: block; font-size: 14px; } - /* ── ESCALATION ALERT ── */ .escalation-alert { - background: #fef9c3; - border: 1.5px solid #eab308; - border-radius: 8px; - padding: 12px 16px; - margin-top: 14px; - font-size: 12px; - color: #713f12; - display: flex; - align-items: center; - gap: 10px; + background: #fef9c3; border: 1.5px solid #eab308; border-radius: 8px; + padding: 12px 16px; margin-top: 14px; font-size: 12px; color: #713f12; + display: flex; align-items: center; gap: 10px; } .escalation-icon { font-size: 18px; } - /* ── TIER TABLE ── */ .tier-table { width: 100%; border-collapse: collapse; } - .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; - } - .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; - } + .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; } + .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 { - background: #eff6ff; - border-left: 4px solid #3b82f6; - border-radius: 0 6px 6px 0; - padding: 12px 16px; - font-size: 11.5px; - color: #1e40af; - line-height: 1.6; + background: #eff6ff; border-left: 4px solid #3b82f6; 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-block { } - .sig-line { - border-bottom: 1.5px solid #334155; - margin-bottom: 8px; - min-height: 52px; - } - .sig-line-label { - 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; - } + .sig-line { border-bottom: 1.5px solid #334155; margin-bottom: 8px; min-height: 52px; } + .sig-line-label { 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; } + + .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; } + .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; } - /* ── FOOTER BAR ── */ .footer-bar { - margin-top: 32px; - padding: 10px 0 0; - border-top: 1px solid #e2e8f0; - font-size: 10px; - color: #94a3b8; - display: flex; - justify-content: space-between; + margin-top: 32px; padding: 10px 0 0; border-top: 1px solid #e2e8f0; + font-size: 10px; color: #94a3b8; display: flex; justify-content: space-between; } @@ -350,7 +178,7 @@ function buildHtml(v, score) {
- + ${logoTag}
CPAS Violation Record
Comprehensive Professional Accountability System
@@ -362,7 +190,7 @@ function buildHtml(v, score) {
-
⚑ Confidential — Authorized HR & Management Use Only
+
\u26D1 Confidential \u2014 Authorized HR & Management Use Only
@@ -379,15 +207,15 @@ function buildHtml(v, score) {
Department
-
${v.department || '—'}
+
${v.department || '\u2014'}
Supervisor
-
${v.supervisor || '—'}
+
${v.supervisor || '\u2014'}
Witness / Documenting Officer
-
${v.witness_name || '—'}
+
${v.witness_name || '\u2014'}
@@ -468,7 +296,7 @@ function buildHtml(v, score) { ${escalated ? `
- + \u26A0
Tier Escalation: This violation advances the employee from ${priorTier.label} @@ -493,13 +321,13 @@ function buildHtml(v, score) {
${TIERS.map(t => { 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 ` - + `; }).join('')} @@ -517,7 +345,7 @@ function buildHtml(v, score) {
-
Acknowledgement & Signatures
+
Acknowledgement & Signatures${hasAck ? 'Acknowledged' : ''}

@@ -526,9 +354,13 @@ function buildHtml(v, score) {

-
+ ${hasAck + ? `
${ackName}
` + : '
'}
Employee Signature
- + ${hasAck && ackDate + ? `
${ackDate}
` + : ''}
Date
@@ -541,8 +373,8 @@ function buildHtml(v, score) {
diff --git a/server.js b/server.js index 01216e2..59ed7d3 100755 --- a/server.js +++ b/server.js @@ -27,7 +27,7 @@ function audit(action, entityType, entityId, performedBy, details) { // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); -// ── Employees ───────────────────────────────────────────────────────────────── +// ── Employees ──────────────────────────────────────────────────────────────── app.get('/api/employees', (req, res) => { const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all(); res.json(rows); @@ -50,7 +50,7 @@ app.post('/api/employees', (req, res) => { res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); -// ── Employee Edit ───────────────────────────────────────────────────────────── +// ── Employee Edit ──────────────────────────────────────────────────────────── // PATCH /api/employees/:id — update name, department, supervisor, or notes app.patch('/api/employees/:id', (req, res) => { 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 }); }); -// ── Employee Merge ──────────────────────────────────────────────────────────── +// ── Employee Merge ─────────────────────────────────────────────────────────── // POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source app.post('/api/employees/:id/merge', (req, res) => { const targetId = parseInt(req.params.id); @@ -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 }); }); -// ── Expiration Timeline ─────────────────────────────────────────────────────── +// ── Expiration Timeline ────────────────────────────────────────────────────── // GET /api/employees/:id/expiration — active violations sorted by roll-off date // Returns each active violation with days_remaining until it exits the 90-day window. app.get('/api/employees/:id/expiration', (req, res) => { @@ -151,7 +151,7 @@ app.get('/api/employees/:id/expiration', (req, res) => { JULIANDAY(DATE(v.incident_date, '+90 days')) - JULIANDAY(DATE('now')) AS INTEGER - ) AS days_remaining + ) AS days_remaining FROM violations v WHERE v.employee_id = ? AND v.negated = 0 @@ -190,7 +190,7 @@ app.get('/api/violations/employee/:id', (req, res) => { res.json(rows); }); -// ── Violation amendment history ─────────────────────────────────────────────── +// ── Violation amendment history ────────────────────────────────────────────── app.get('/api/violations/:id/amendments', (req, res) => { const rows = db.prepare(` SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC @@ -216,7 +216,8 @@ app.post('/api/violations', (req, res) => { const { employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, - details, submitted_by, witness_name + details, submitted_by, witness_name, + acknowledged_by, acknowledged_date } = req.body; 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, points, incident_date, incident_time, location, details, submitted_by, witness_name, - prior_active_points - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prior_active_points, + acknowledged_by, acknowledged_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( employee_id, violation_type, violation_name || violation_type, category || 'General', ptsInt, incident_date, incident_time || null, location || 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, { @@ -248,9 +251,9 @@ app.post('/api/violations', (req, res) => { 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 -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) => { const id = parseInt(req.params.id); @@ -295,7 +298,7 @@ app.patch('/api/violations/:id/amend', (req, res) => { res.json(updated); }); -// ── Negate a violation ──────────────────────────────────────────────────────── +// ── Negate a violation ─────────────────────────────────────────────────────── app.patch('/api/violations/:id/negate', (req, res) => { const { resolution_type, details, resolved_by } = req.body; const id = req.params.id; @@ -323,7 +326,7 @@ app.patch('/api/violations/:id/negate', (req, res) => { res.json({ success: true }); }); -// ── Restore a negated violation ─────────────────────────────────────────────── +// ── Restore a negated violation ────────────────────────────────────────────── app.patch('/api/violations/:id/restore', (req, res) => { const id = req.params.id; @@ -337,7 +340,7 @@ app.patch('/api/violations/:id/restore', (req, res) => { res.json({ success: true }); }); -// ── Hard delete a violation ─────────────────────────────────────────────────── +// ── Hard delete a violation ────────────────────────────────────────────────── app.delete('/api/violations/:id', (req, res) => { const id = req.params.id; @@ -353,7 +356,7 @@ app.delete('/api/violations/:id', (req, res) => { res.json({ success: true }); }); -// ── Audit log ───────────────────────────────────────────────────────────────── +// ── Audit log ──────────────────────────────────────────────────────────────── app.get('/api/audit', (req, res) => { const limit = Math.min(parseInt(req.query.limit) || 100, 500); const offset = parseInt(req.query.offset) || 0; @@ -372,7 +375,7 @@ app.get('/api/audit', (req, res) => { res.json(db.prepare(sql).all(...args)); }); -// ── PDF endpoint ────────────────────────────────────────────────────────────── +// ── PDF endpoint ───────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(` @@ -399,7 +402,7 @@ app.get('/api/violations/:id/pdf', async (req, res) => { res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`, - 'Content-Length': pdfBuffer.length, + 'Content-Length': pdfBuffer.length, }); res.end(pdfBuffer); } catch (err) {
${inline(c)}
${inline(c)}
${active ? '▶ ' : ''}${range}${active ? '\u25B6 ' : ''}${range} ${t.label} - ${active ? ' ← Current' : ''} + ${active ? ' \u2190 Current' : ''}