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:
commit
9c4c357cbe
10 files changed
+431
-355
No files matched your search
@@ -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_<EmployeeName>_<IncidentDate>.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
|
||||
|
||||
|
||||
+24
-21
@@ -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 (
|
||||
<div style={s.app}>
|
||||
<nav style={s.nav}>
|
||||
<div style={s.logoWrap}>
|
||||
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
|
||||
<div style={s.logoText}>CPAS Tracker</div>
|
||||
<ToastProvider>
|
||||
<div style={s.app}>
|
||||
<nav style={s.nav}>
|
||||
<div style={s.logoWrap}>
|
||||
<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>
|
||||
|
||||
{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 />}
|
||||
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
||||
</div>
|
||||
|
||||
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
<div style={{ ...s.scoreCard, minWidth: '140px' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}>
|
||||
{tier ? tier.label : '–'}
|
||||
{tier ? tier.label : '—'}
|
||||
</div>
|
||||
<div style={s.scoreLbl}>Current Tier</div>
|
||||
</div>
|
||||
@@ -405,14 +428,14 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
||||
<EditEmployeeModal
|
||||
employee={employee}
|
||||
onClose={() => setEditingEmp(false)}
|
||||
onSaved={load}
|
||||
onSaved={() => { toast.success('Employee updated.'); load(); }}
|
||||
/>
|
||||
)}
|
||||
{amending && (
|
||||
<AmendViolationModal
|
||||
violation={amending}
|
||||
onClose={() => setAmending(null)}
|
||||
onSaved={load}
|
||||
onSaved={() => { toast.success('Violation amended.'); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; }
|
||||
if (line.trim().startsWith('|')) {
|
||||
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; }
|
||||
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; }
|
||||
else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); 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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
)}
|
||||
</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}>
|
||||
<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); }}>
|
||||
@@ -298,7 +328,7 @@ export default function ViolationForm() {
|
||||
</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.employeeId && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
+103
-271
@@ -1,14 +1,32 @@
|
||||
// Inline base64 logo — avoids Puppeteer needing a live HTTP request to /static/
|
||||
const LOGO_B64 = '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
Line truncated
|
||||
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 `
|
||||
<div class="field">
|
||||
<div class="field-label">${label}</div>
|
||||
<div class="field-value">${value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<img src="${LOGO_DATA_URI}" class="logo" />`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -350,7 +178,7 @@ function buildHtml(v, score) {
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<img src="data:image/png;base64,${LOGO_B64}" class="logo" />
|
||||
${logoTag}
|
||||
<div>
|
||||
<div class="header-title">CPAS Violation Record</div>
|
||||
<div class="header-sub">Comprehensive Professional Accountability System</div>
|
||||
@@ -362,7 +190,7 @@ function buildHtml(v, score) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confidential-bar">⚑ Confidential — Authorized HR & Management Use Only</div>
|
||||
<div class="confidential-bar">\u26D1 Confidential \u2014 Authorized HR & Management Use Only</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
@@ -379,15 +207,15 @@ function buildHtml(v, score) {
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Department</div>
|
||||
<div class="field-value">${v.department || '—'}</div>
|
||||
<div class="field-value">${v.department || '\u2014'}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Supervisor</div>
|
||||
<div class="field-value">${v.supervisor || '—'}</div>
|
||||
<div class="field-value">${v.supervisor || '\u2014'}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<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>
|
||||
@@ -468,7 +296,7 @@ function buildHtml(v, score) {
|
||||
|
||||
${escalated ? `
|
||||
<div class="escalation-alert">
|
||||
<span class="escalation-icon">⚠</span>
|
||||
<span class="escalation-icon">\u26A0</span>
|
||||
<div>
|
||||
<strong>Tier Escalation:</strong>
|
||||
This violation advances the employee from <strong>${priorTier.label}</strong>
|
||||
@@ -493,13 +321,13 @@ function buildHtml(v, score) {
|
||||
<tbody>
|
||||
${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 `<tr class="${active ? 'current-tier' : ''}">
|
||||
<td>${active ? '▶ ' : ''}${range}</td>
|
||||
<td>${active ? '\u25B6 ' : ''}${range}</td>
|
||||
<td>
|
||||
<span class="tier-dot" style="background:${t.color === '#ffffff' ? t.bg : t.color};"></span>
|
||||
${t.label}
|
||||
${active ? '<strong> ← Current</strong>' : ''}
|
||||
${active ? '<strong> \u2190 Current</strong>' : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
@@ -517,7 +345,7 @@ function buildHtml(v, score) {
|
||||
<!-- Signatures -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Acknowledgement & Signatures</div>
|
||||
<div class="section-title">Acknowledgement & Signatures${hasAck ? '<span class="ack-badge">Acknowledged</span>' : ''}</div>
|
||||
<div class="section-rule"></div>
|
||||
</div>
|
||||
<p class="sig-intro">
|
||||
@@ -526,9 +354,13 @@ function buildHtml(v, score) {
|
||||
</p>
|
||||
<div class="sig-grid">
|
||||
<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-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>
|
||||
<div class="sig-block">
|
||||
@@ -541,8 +373,8 @@ function buildHtml(v, score) {
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span>${docId} · ${v.employee_name} · Incident: ${v.incident_date}</span>
|
||||
<span>Message Point Media — Internal Use Only</span>
|
||||
<span>${docId} \u00B7 ${v.employee_name} \u00B7 Incident: ${v.incident_date}</span>
|
||||
<span>Message Point Media \u2014 Internal Use Only</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user