From 554b39672fbbaa8a40290f5390d36567b7038b23 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 18:38:33 -0600 Subject: [PATCH 01/54] docs: replace Unraid stub with verified working install settings from production server --- README.md | 128 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 98 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5b16a3d..25b3b87 100755 --- a/README.md +++ b/README.md @@ -27,14 +27,6 @@ docker run -d --name cpas-tracker \ # http://localhost:3001 ``` -## Export for Unraid - -```bash -docker save cpas-tracker | gzip > cpas-tracker.tar.gz -``` - -Then follow README_UNRAID_INSTALL.md. - ## Update After Code Changes ```bash @@ -45,6 +37,82 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker --- +## Deploying on Unraid + +### Step 1 — Build and export the image on your dev machine + +```bash +docker build -t cpas:latest . +docker save cpas:latest | gzip > cpas-latest.tar.gz +``` + +### Step 2 — Load the image on Unraid + +Transfer `cpas-latest.tar.gz` to your Unraid server, then load it via the Unraid terminal: + +```bash +docker load < /path/to/cpas-latest.tar.gz +``` + +Confirm the image is present: + +```bash +docker images | grep cpas +``` + +### Step 3 — Create the appdata directory + +```bash +mkdir -p /mnt/user/appdata/cpas/db +``` + +### Step 4 — Run the container + +This is the verified working `docker run` command for Unraid (bridge networking with static IP): + +```bash +docker run \ + -d \ + --name='cpas' \ + --net='br0' \ + --ip='10.2.0.14' \ + --pids-limit 2048 \ + -e TZ="America/Chicago" \ + -e HOST_OS="Unraid" \ + -e HOST_HOSTNAME="ALPHA" \ + -e HOST_CONTAINERNAME="cpas" \ + -e 'PORT'='3001' \ + -e 'DB_PATH'='/data/cpas.db' \ + -l net.unraid.docker.managed=dockerman \ + -l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \ + -v '/mnt/user/appdata/cpas/db':'/data':'rw' \ + cpas:latest +``` + +Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). + +### Key settings explained + +| Setting | Value | Notes | +|---------|-------|-------| +| `--net` | `br0` | Unraid custom bridge network — gives the container its own LAN IP | +| `--ip` | `10.2.0.14` | Static IP on your LAN — adjust to match your subnet | +| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures | +| `PORT` | `3001` | Express listen port inside the container | +| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container | +| Volume | `/mnt/user/appdata/cpas/db` → `/data` | Persists the database across container restarts and rebuilds | + +### Updating on Unraid + +1. Build and export the new image on your dev machine (Step 1 above) +2. Load it on Unraid: `docker load < cpas-latest.tar.gz` +3. Stop and remove the old container: `docker stop cpas && docker rm cpas` +4. Re-run the `docker run` command from Step 4 — the volume mount preserves all data + +> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container. + +--- + ## Features ### Company Dashboard @@ -143,16 +211,16 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude ``` cpas/ -├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium +├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium ├── .dockerignore -├── package.json # Backend (Express) deps -├── server.js # API + static file server +├── package.json # Backend (Express) deps +├── server.js # API + static file server ├── db/ -│ ├── schema.sql # Tables + 90-day active score view -│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations +│ ├── schema.sql # Tables + 90-day active score view +│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations ├── pdf/ -│ └── generator.js # Puppeteer PDF generation -└── client/ # React frontend (Vite) +│ └── generator.js # Puppeteer PDF generation +└── client/ # React frontend (Vite) ├── package.json ├── vite.config.js ├── index.html @@ -160,23 +228,23 @@ cpas/ ├── main.jsx ├── App.jsx ├── data/ - │ └── violations.js # All CPAS violation definitions + groups + │ └── violations.js # All CPAS violation definitions + groups ├── hooks/ - │ └── useEmployeeIntelligence.js # Score + history hook + │ └── useEmployeeIntelligence.js # Score + history hook └── components/ - ├── 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 - ├── EmployeeModal.jsx # Employee profile + history modal - ├── EditEmployeeModal.jsx # Employee edit + merge duplicate - ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history - ├── AuditLog.jsx # Filterable audit log panel - ├── NegateModal.jsx # Negate/resolve violation dialog - ├── ViolationHistory.jsx # Violation list component - ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown - ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags - └── ReadmeModal.jsx # In-app admin documentation panel + ├── 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 + ├── EmployeeModal.jsx # Employee profile + history modal + ├── EditEmployeeModal.jsx # Employee edit + merge duplicate + ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history + ├── AuditLog.jsx # Filterable audit log panel + ├── NegateModal.jsx # Negate/resolve violation dialog + ├── ViolationHistory.jsx # Violation list component + ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown + ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags + └── ReadmeModal.jsx # In-app admin documentation panel ``` --- -- 2.52.0 From 281825377f8c4ed3af70d2bd7dccb3a2c899d428 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 18:39:01 -0600 Subject: [PATCH 02/54] =?UTF-8?q?feat:=20ReadmeModal=20=E2=80=94=20admin?= =?UTF-8?q?=20usage=20guide,=20feature=20map,=20workflow=20reference,=20ro?= =?UTF-8?q?admap=20(no=20install=20content)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ReadmeModal.jsx | 177 +++++++++----------------- 1 file changed, 57 insertions(+), 120 deletions(-) diff --git a/client/src/components/ReadmeModal.jsx b/client/src/components/ReadmeModal.jsx index edadd0a..8819b89 100644 --- a/client/src/components/ReadmeModal.jsx +++ b/client/src/components/ReadmeModal.jsx @@ -1,17 +1,16 @@ import React, { useEffect, useRef } from 'react'; -// ─── Minimal Markdown → HTML renderer ──────────────────────────────────────── +// Minimal Markdown to HTML renderer (headings, bold, inline-code, tables, hr, ul, ol, paragraphs) function mdToHtml(md) { const lines = md.split('\n'); const out = []; let i = 0, inUl = false, inOl = false, inTable = false; const close = () => { - if (inUl) { out.push(''); inUl = false; } - if (inOl) { out.push(''); inOl = false; } - if (inTable) { out.push(''); inTable = false; } + if (inUl) { out.push(''); inUl = false; } + if (inOl) { out.push(''); inOl = false; } + if (inTable) { out.push(''); inTable = false; } }; - const inline = s => s.replace(/&/g,'&').replace(//g,'>') .replace(/\*\*(.+?)\*\*/g,'$1') @@ -19,121 +18,72 @@ function mdToHtml(md) { while (i < lines.length) { const line = lines[i]; - - if (line.startsWith('```')) { - close(); - i++; - while (i < lines.length && !lines[i].startsWith('```')) i++; - i++; continue; - } + if (line.startsWith('```')) { close(); i++; while (i < lines.length && !lines[i].startsWith('```')) i++; i++; continue; } if (/^---+$/.test(line.trim())) { close(); out.push('
'); i++; continue; } - const hm = line.match(/^(#{1,4})\s+(.+)/); - if (hm) { - close(); - const lvl = hm[1].length; - const id = hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); - out.push(`${inline(hm[2])}`); - i++; continue; - } - + 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; - } else { - out.push(''); - cells.forEach(c => out.push(``)); - out.push(''); - i++; continue; - } + if (!inTable) { close(); inTable=true; out.push('
${inline(c)}
${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+(.*)/); - if (ul) { - if (inTable) close(); - if (!inUl) { if (inOl) { out.push(''); inOl=false; } out.push('');inUl=false;} out.push('
    ');inOl=true; } out.push(`
  1. ${inline(ol[1])}
  2. `); i++; continue; } if (line.trim() === '') { close(); i++; continue; } - - close(); - out.push(`

    ${inline(line)}

    `); - i++; + close(); out.push(`

    ${inline(line)}

    `); i++; } close(); return out.join('\n'); } +function buildToc(md) { + return md.split('\n').reduce((acc, line) => { + const m = line.match(/^(#{1,2})\s+(.+)/); + if (m) acc.push({ level: m[1].length, text: m[2], id: m[2].toLowerCase().replace(/[^a-z0-9]+/g,'-') }); + return acc; + }, []); +} + // ─── 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:'780px', maxWidth:'95vw', - height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', - display:'flex', flexDirection:'column', - }, - header: { - background:'linear-gradient(135deg,#000000,#151622)', color:'white', - padding:'22px 28px', position:'sticky', top:0, zIndex:10, - borderBottom:'1px solid #222', display:'flex', alignItems:'center', - justifyContent:'space-between', - }, - closeBtn: { background:'none', border:'none', color:'white', fontSize:'22px', cursor:'pointer', lineHeight:1 }, - toc: { - background:'#0d1117', borderBottom:'1px solid #1e1f2e', - padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px', - }, - body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' }, - footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' }, + 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' }, + header: { background:'linear-gradient(135deg,#000000,#151622)', color:'white', padding:'22px 28px', position:'sticky', top:0, zIndex:10, borderBottom:'1px solid #222', display:'flex', alignItems:'center', justifyContent:'space-between' }, + closeBtn:{ background:'none', border:'none', color:'white', fontSize:'22px', cursor:'pointer', lineHeight:1 }, + toc: { background:'#0d1117', borderBottom:'1px solid #1e1f2e', padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px' }, + body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' }, + footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' }, }; const CSS = ` -.adm h1 { font-size:21px; font-weight:800; color:#f8f9fa; margin:28px 0 10px; border-bottom:1px solid #2a2b3a; padding-bottom:8px; } -.adm h2 { font-size:16px; font-weight:700; color:#d4af37; margin:28px 0 6px; letter-spacing:.2px; } -.adm h3 { font-size:12px; font-weight:700; color:#90caf9; margin:18px 0 4px; text-transform:uppercase; letter-spacing:.5px; } -.adm h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px; } -.adm p { color:#c8ccd8; margin:5px 0 10px; } -.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0; } -.adm strong { color:#f8f9fa; } -.adm code { background:#0d1117; color:#79c0ff; border:1px solid #2a2b3a; border-radius:4px; padding:1px 6px; font-family:'Consolas','Fira Code',monospace; font-size:12px; } -.adm ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8; } -.adm ol { padding-left:20px; margin:5px 0 10px; color:#c8ccd8; } -.adm li { margin:4px 0; } -.adm table { width:100%; border-collapse:collapse; font-size:12px; background:#181924; border-radius:6px; overflow:hidden; border:1px solid #2a2b3a; margin:10px 0 16px; } -.adm th { background:#050608; padding:8px 12px; text-align:left; color:#f8f9fa; font-weight:600; font-size:11px; text-transform:uppercase; border-bottom:1px solid #2a2b3a; } -.adm td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8; } -.adm tr:last-child td { border-bottom:none; } -.adm tr:hover td { background:#1e1f2e; } +.adm h1 { font-size:21px; font-weight:800; color:#f8f9fa; margin:28px 0 10px; border-bottom:1px solid #2a2b3a; padding-bottom:8px } +.adm h2 { font-size:16px; font-weight:700; color:#d4af37; margin:28px 0 6px; letter-spacing:.2px } +.adm h3 { font-size:12px; font-weight:700; color:#90caf9; margin:18px 0 4px; text-transform:uppercase; letter-spacing:.5px } +.adm h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px } +.adm p { color:#c8ccd8; margin:5px 0 10px } +.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0 } +.adm strong { color:#f8f9fa } +.adm code { background:#0d1117; color:#79c0ff; border:1px solid #2a2b3a; border-radius:4px; padding:1px 6px; font-family:'Consolas','Fira Code',monospace; font-size:12px } +.adm ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 } +.adm ol { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 } +.adm li { margin:4px 0 } +.adm table { width:100%; border-collapse:collapse; font-size:12px; background:#181924; border-radius:6px; overflow:hidden; border:1px solid #2a2b3a; margin:10px 0 16px } +.adm th { background:#050608; padding:8px 12px; text-align:left; color:#f8f9fa; font-weight:600; font-size:11px; text-transform:uppercase; border-bottom:1px solid #2a2b3a } +.adm td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8 } +.adm tr:last-child td { border-bottom:none } +.adm tr:hover td { background:#1e1f2e } `; -// ─── Admin guide 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. --- -## How CPAS Scoring Works +## How Scoring Works Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates. @@ -161,7 +111,7 @@ The **at-risk badge** on the dashboard flags anyone within 2 points of the next The main view. Employees are sorted by active CPAS points, highest first. -- **Stat cards** — live counts: total employees, zero-point (elite), active points, at-risk, highest score +- **Stat cards** — live counts: total employees, zero-point (elite), with active points, at-risk, highest score - **Search / filter** — by name, department, or supervisor; narrows the table in real time - **At-risk badge** — gold flag on rows where the employee is within 2 pts of the next tier - **Audit Log button** — opens the filterable, paginated write-action log (top right of the dashboard toolbar) @@ -254,7 +204,7 @@ Each amendment stores a before/after diff for every changed field. Amendment his ## Roadmap -### ✅ Shipped +### Shipped - Container scaffold, violation form, employee intelligence - Recidivist auto-escalation, tier crossing warning @@ -270,7 +220,7 @@ Each amendment stores a before/after diff for every changed field. Amendment his --- -### 🔜 Near-term +### Near-term These are well-scoped additions that fit the current architecture without major changes. @@ -280,7 +230,7 @@ These are well-scoped additions that fit the current architecture without major --- -### 📋 Planned +### Planned Larger features that require more design work or infrastructure. @@ -291,27 +241,18 @@ Larger features that require more design work or infrastructure. --- -### 🔭 Future Considerations +### Future Considerations These require meaningful infrastructure additions and should be evaluated against actual operational need before committing. - **Multi-user auth** — role-based login (admin, supervisor, read-only). Currently the app assumes a trusted internal network with no authentication layer. - **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+, automatically routed to their supervisor. - **Scheduled digest** — weekly email summary to supervisors showing their employees' current standings and any approaching thresholds. -- **Automated DB backup** — scheduled snapshot of `/data/cpas.db` to a mounted backup volume or remote destination. +- **Automated DB backup** — scheduled snapshot of the database to a mounted backup volume or remote destination. - **Bulk CSV import** — migrate historical violation records from paper logs or a prior system. - **Dark/light theme toggle** — UI is currently dark-only. `; -// ─── TOC builder ───────────────────────────────────────────────────────────── -function buildToc(md) { - return md.split('\n').reduce((acc, line) => { - const m = line.match(/^(#{1,2})\s+(.+)/); - if (m) acc.push({ level: m[1].length, text: m[2], id: m[2].toLowerCase().replace(/[^a-z0-9]+/g,'-') }); - return acc; - }, []); -} - // ─── Component ──────────────────────────────────────────────────────────────── export default function ReadmeModal({ onClose }) { const bodyRef = useRef(null); @@ -342,7 +283,7 @@ export default function ReadmeModal({ onClose }) { 📋 CPAS Tracker — Admin Guide
    - Feature map · workflow reference · roadmap · Esc or click outside to close + Feature map · workflows · roadmap · Esc or click outside to close
    @@ -351,16 +292,12 @@ export default function ReadmeModal({ onClose }) { {/* TOC strip */}
    {toc.map(h => ( - ))} -- 2.52.0 From bfa46e93b67381279bcdc10fd7370c010e735e73 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 18:58:28 -0600 Subject: [PATCH 03/54] docs: fix local quickstart image name to cpas (not cpas-tracker) --- README.md | 325 ++---------------------------------------------------- 1 file changed, 7 insertions(+), 318 deletions(-) diff --git a/README.md b/README.md index 25b3b87..b4013c4 100755 --- a/README.md +++ b/README.md @@ -1,27 +1,14 @@ -# CPAS Violation Tracker - -Single-container Dockerized web app for CPAS violation documentation and workforce standing management. -Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation). - ---- - -## The only requirement on your machine: Docker Desktop - -Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker. - ---- - ## Quickstart (Local) ```bash # 1. Build the image (installs all deps + compiles React inside Docker) -docker build -t cpas-tracker . +docker build -t cpas . # 2. Run it -docker run -d --name cpas-tracker \ +docker run -d --name cpas \ -p 3001:3001 \ -v cpas-data:/data \ - cpas-tracker + cpas # 3. Open # http://localhost:3001 @@ -30,305 +17,7 @@ docker run -d --name cpas-tracker \ ## Update After Code Changes ```bash -docker build -t cpas-tracker . -docker stop cpas-tracker && docker rm cpas-tracker -docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker -``` - ---- - -## Deploying on Unraid - -### Step 1 — Build and export the image on your dev machine - -```bash -docker build -t cpas:latest . -docker save cpas:latest | gzip > cpas-latest.tar.gz -``` - -### Step 2 — Load the image on Unraid - -Transfer `cpas-latest.tar.gz` to your Unraid server, then load it via the Unraid terminal: - -```bash -docker load < /path/to/cpas-latest.tar.gz -``` - -Confirm the image is present: - -```bash -docker images | grep cpas -``` - -### Step 3 — Create the appdata directory - -```bash -mkdir -p /mnt/user/appdata/cpas/db -``` - -### Step 4 — Run the container - -This is the verified working `docker run` command for Unraid (bridge networking with static IP): - -```bash -docker run \ - -d \ - --name='cpas' \ - --net='br0' \ - --ip='10.2.0.14' \ - --pids-limit 2048 \ - -e TZ="America/Chicago" \ - -e HOST_OS="Unraid" \ - -e HOST_HOSTNAME="ALPHA" \ - -e HOST_CONTAINERNAME="cpas" \ - -e 'PORT'='3001' \ - -e 'DB_PATH'='/data/cpas.db' \ - -l net.unraid.docker.managed=dockerman \ - -l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \ - -v '/mnt/user/appdata/cpas/db':'/data':'rw' \ - cpas:latest -``` - -Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). - -### Key settings explained - -| Setting | Value | Notes | -|---------|-------|-------| -| `--net` | `br0` | Unraid custom bridge network — gives the container its own LAN IP | -| `--ip` | `10.2.0.14` | Static IP on your LAN — adjust to match your subnet | -| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures | -| `PORT` | `3001` | Express listen port inside the container | -| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container | -| Volume | `/mnt/user/appdata/cpas/db` → `/data` | Persists the database across container restarts and rebuilds | - -### Updating on Unraid - -1. Build and export the new image on your dev machine (Step 1 above) -2. Load it on Unraid: `docker load < cpas-latest.tar.gz` -3. Stop and remove the old container: `docker stop cpas && docker rm cpas` -4. Re-run the `docker run` command from Step 4 — the volume mount preserves all data - -> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container. - ---- - -## Features - -### Company Dashboard -- Live table of all employees sorted by active CPAS points (highest risk first) -- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score -- **At-risk badge**: flags employees within 2 points of the next tier escalation -- Search/filter by name, department, or supervisor -- Click any employee name to open their full profile modal -- **🔍 Audit Log** button — filterable, paginated view of all system write actions - -### Violation Form -- Select existing employee or enter new employee by name -- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting -- Violation type dropdown grouped by category; shows prior 90-day counts inline -- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy -- Repeat offense badge with prior count displayed -- 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 -- One-click PDF download immediately after submission - -### 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 -- 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 - -### Audit Log -- Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted -- Filterable by entity type (employee / violation) and action -- Paginated with load-more; accessible from the Dashboard toolbar - -### Violation Amendment -- Edit submitted violations' non-scoring fields without delete-and-resubmit -- Point values, violation type, and incident date are immutable -- Every change is stored as a field-level diff (old → new value) with timestamp and actor - -### In-App Documentation -- **? Docs** button in the navbar opens a slide-in admin reference panel -- Covers feature map, CPAS tier system, workflow guidance, and roadmap -- No external link required; always reflects current deployed version - -### CPAS Tier System - -| Points | Tier | Label | -|--------|------|-------| -| 0–4 | 0–1 | Elite Standing | -| 5–9 | 1 | Realignment | -| 10–14 | 2 | Administrative Lockdown | -| 15–19 | 3 | Verification | -| 20–24 | 4 | Risk Mitigation | -| 25–29 | 5 | Final Decision | -| 30+ | 6 | Separation | - -Scores are computed over a **rolling 90-day window** (negated violations excluded). - -### PDF Generation -- Puppeteer + system Chromium (bundled in Docker image) -- 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) - ---- - -## API Reference - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/api/health` | Health check | -| GET | `/api/employees` | List all employees (includes `notes`) | -| POST | `/api/employees` | Create or upsert employee | -| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes | -| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations | -| GET | `/api/employees/:id/score` | Get active CPAS score for employee | -| 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 | -| 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 | -| PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging | -| GET | `/api/violations/:id/amendments` | Get amendment history for a violation | -| DELETE | `/api/violations/:id` | Hard delete a violation | -| GET | `/api/violations/:id/pdf` | Download violation PDF | -| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) | - ---- - -## Project Structure - -``` -cpas/ -├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium -├── .dockerignore -├── package.json # Backend (Express) deps -├── server.js # API + static file server -├── db/ -│ ├── schema.sql # Tables + 90-day active score view -│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations -├── pdf/ -│ └── generator.js # Puppeteer PDF generation -└── client/ # React frontend (Vite) - ├── package.json - ├── vite.config.js - ├── index.html - └── src/ - ├── main.jsx - ├── App.jsx - ├── data/ - │ └── violations.js # All CPAS violation definitions + groups - ├── hooks/ - │ └── useEmployeeIntelligence.js # Score + history hook - └── components/ - ├── 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 - ├── EmployeeModal.jsx # Employee profile + history modal - ├── EditEmployeeModal.jsx # Employee edit + merge duplicate - ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history - ├── AuditLog.jsx # Filterable audit log panel - ├── NegateModal.jsx # Negate/resolve violation dialog - ├── ViolationHistory.jsx # Violation list component - ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown - ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags - └── ReadmeModal.jsx # In-app admin documentation panel -``` - ---- - -## Database Schema - -Six tables + one view: - -- **`employees`** — id, name, department, supervisor, **notes** -- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging -- **`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) -- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee - ---- - -## Amendable Fields - -Point values, violation type, and incident date are **immutable** after submission. The following fields can be amended: - -| Field | Notes | -|-------|-------| -| `incident_time` | Time of day the incident occurred | -| `location` | Where the incident took place | -| `details` | Narrative description | -| `submitted_by` | Supervisor who submitted | -| `witness_name` | Witness on record | - ---- - -## Roadmap - -### ✅ Completed - -| Phase | Feature | Description | -|-------|---------|-------------| -| 1 | Container scaffold | Docker multi-stage build, Express server, SQLite schema | -| 1 | Base violation form | Employee fields, violation type, incident date, point submission | -| 2 | Employee intelligence | Live CPAS standing badge and 90-day count shown before submitting | -| 2 | Prior violation highlighting | Violation dropdown annotates types with 90-day recurrence counts | -| 2 | Recidivist auto-escalation | Points slider auto-maximizes on repeat same-type violations | -| 2 | Violation history | Per-employee history list with resolution status | -| 3 | PDF generation | Puppeteer/Chromium PDF per violation, downloadable immediately post-submit | -| 3 | Prior-points snapshot | `prior_active_points` captured at insert time for accurate historical PDFs | -| 4 | Company dashboard | Sortable employee table with live tier badges and at-risk flags | -| 4 | Stat cards | Summary counts: total, clean, active, at-risk, highest score | -| 4 | Tier crossing warning | Pre-submit alert when new points push employee to next tier | -| 4 | Employee profile modal | Full history, negate/restore, hard delete, per-record PDF download | -| 4 | Negate & restore | Soft-delete violations with resolution type + notes, fully reversible | -| 5 | Employee edit / merge | Update employee name/dept/supervisor; merge duplicate records without losing history | -| 5 | Violation amendment | Edit non-scoring fields with field-level audit trail | -| 5 | Audit log | Append-only log of all system writes; filterable panel in the dashboard | -| 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 | - ---- - -### 📋 Proposed - -#### Reporting & Analytics -- **Violation trends chart** — line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns vs. individual incidents -- **Department heat map** — grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk -- **CSV / Excel export** — bulk export of violations or dashboard data for external reporting or payroll integration - -#### Employee Management -- **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 - -#### Notifications & Escalation -- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed -- **Scheduled summary digest** — weekly email to supervisors listing their employees' current standings and any approaching tier thresholds -- **At-risk threshold configuration** — make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment - -#### Infrastructure & Ops -- **Multi-user auth** — simple login with role-based access (admin, supervisor, read-only); currently the app has no auth and is assumed to run on a trusted internal network -- **Automated DB backup** — cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule -- **Dark/light theme toggle** — the UI is currently dark-only; a toggle would improve usability in bright environments - ---- - -*Proposed features are suggestions based on common HR documentation workflows. Priority and implementation order should be driven by actual operational needs.* +docker build -t cpas . +docker stop cpas && docker rm cpas +docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas +``` \ No newline at end of file -- 2.52.0 From 0920bffc5028eaaa1d66b7b3d63ec3f426906f6a Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 19:00:02 -0600 Subject: [PATCH 04/54] docs: restore full README with corrected local image name (cpas not cpas-tracker) --- README.md | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b4013c4..fa09458 100755 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ +# CPAS Violation Tracker + +Single-container Dockerized web app for CPAS violation documentation and workforce standing management. +Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation). + +--- + +## The only requirement on your machine: Docker Desktop + +Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker. + +--- + ## Quickstart (Local) ```bash @@ -20,4 +33,302 @@ docker run -d --name cpas \ docker build -t cpas . docker stop cpas && docker rm cpas docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas -``` \ No newline at end of file +``` + +--- + +## Deploying on Unraid + +### Step 1 — Build and export the image on your dev machine + +```bash +docker build -t cpas:latest . +docker save cpas:latest | gzip > cpas-latest.tar.gz +``` + +### Step 2 — Load the image on Unraid + +Transfer `cpas-latest.tar.gz` to your Unraid server, then load it via the Unraid terminal: + +```bash +docker load < /path/to/cpas-latest.tar.gz +``` + +Confirm the image is present: + +```bash +docker images | grep cpas +``` + +### Step 3 — Create the appdata directory + +```bash +mkdir -p /mnt/user/appdata/cpas/db +``` + +### Step 4 — Run the container + +This is the verified working `docker run` command for Unraid (bridge networking with static IP): + +```bash +docker run \ + -d \ + --name='cpas' \ + --net='br0' \ + --ip='10.2.0.14' \ + --pids-limit 2048 \ + -e TZ="America/Chicago" \ + -e HOST_OS="Unraid" \ + -e HOST_HOSTNAME="ALPHA" \ + -e HOST_CONTAINERNAME="cpas" \ + -e 'PORT'='3001' \ + -e 'DB_PATH'='/data/cpas.db' \ + -l net.unraid.docker.managed=dockerman \ + -l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \ + -v '/mnt/user/appdata/cpas/db':'/data':'rw' \ + cpas:latest +``` + +Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). + +### Key settings explained + +| Setting | Value | Notes | +|---------|-------|-------| +| `--net` | `br0` | Unraid custom bridge network — gives the container its own LAN IP | +| `--ip` | `10.2.0.14` | Static IP on your LAN — adjust to match your subnet | +| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures | +| `PORT` | `3001` | Express listen port inside the container | +| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container | +| Volume | `/mnt/user/appdata/cpas/db` → `/data` | Persists the database across container restarts and rebuilds | + +### Updating on Unraid + +1. Build and export the new image on your dev machine (Step 1 above) +2. Load it on Unraid: `docker load < cpas-latest.tar.gz` +3. Stop and remove the old container: `docker stop cpas && docker rm cpas` +4. Re-run the `docker run` command from Step 4 — the volume mount preserves all data + +> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container. + +--- + +## Features + +### Company Dashboard +- Live table of all employees sorted by active CPAS points (highest risk first) +- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score +- **At-risk badge**: flags employees within 2 points of the next tier escalation +- Search/filter by name, department, or supervisor +- Click any employee name to open their full profile modal +- **🔍 Audit Log** button — filterable, paginated view of all system write actions + +### Violation Form +- Select existing employee or enter new employee by name +- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting +- Violation type dropdown grouped by category; shows prior 90-day counts inline +- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy +- Repeat offense badge with prior count displayed +- 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 +- One-click PDF download immediately after submission + +### 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 +- 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 + +### Audit Log +- Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted +- Filterable by entity type (employee / violation) and action +- Paginated with load-more; accessible from the Dashboard toolbar + +### Violation Amendment +- Edit submitted violations' non-scoring fields without delete-and-resubmit +- Point values, violation type, and incident date are immutable +- Every change is stored as a field-level diff (old → new value) with timestamp and actor + +### In-App Documentation +- **? Docs** button in the navbar opens a slide-in admin reference panel +- Covers feature map, CPAS tier system, workflow guidance, and roadmap +- No external link required; always reflects current deployed version + +### CPAS Tier System + +| Points | Tier | Label | +|--------|------|-------| +| 0–4 | 0–1 | Elite Standing | +| 5–9 | 1 | Realignment | +| 10–14 | 2 | Administrative Lockdown | +| 15–19 | 3 | Verification | +| 20–24 | 4 | Risk Mitigation | +| 25–29 | 5 | Final Decision | +| 30+ | 6 | Separation | + +Scores are computed over a **rolling 90-day window** (negated violations excluded). + +### PDF Generation +- Puppeteer + system Chromium (bundled in Docker image) +- 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) + +--- + +## API Reference + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check | +| GET | `/api/employees` | List all employees (includes `notes`) | +| POST | `/api/employees` | Create or upsert employee | +| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes | +| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations | +| GET | `/api/employees/:id/score` | Get active CPAS score for employee | +| 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 | +| 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 | +| PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging | +| GET | `/api/violations/:id/amendments` | Get amendment history for a violation | +| DELETE | `/api/violations/:id` | Hard delete a violation | +| GET | `/api/violations/:id/pdf` | Download violation PDF | +| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) | + +--- + +## Project Structure + +``` +cpas/ +├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium +├── .dockerignore +├── package.json # Backend (Express) deps +├── server.js # API + static file server +├── db/ +│ ├── schema.sql # Tables + 90-day active score view +│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations +├── pdf/ +│ └── generator.js # Puppeteer PDF generation +└── client/ # React frontend (Vite) + ├── package.json + ├── vite.config.js + ├── index.html + └── src/ + ├── main.jsx + ├── App.jsx + ├── data/ + │ └── violations.js # All CPAS violation definitions + groups + ├── hooks/ + │ └── useEmployeeIntelligence.js # Score + history hook + └── components/ + ├── 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 + ├── EmployeeModal.jsx # Employee profile + history modal + ├── EditEmployeeModal.jsx # Employee edit + merge duplicate + ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history + ├── AuditLog.jsx # Filterable audit log panel + ├── NegateModal.jsx # Negate/resolve violation dialog + ├── ViolationHistory.jsx # Violation list component + ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown + ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags + └── ReadmeModal.jsx # In-app admin documentation panel +``` + +--- + +## Database Schema + +Six tables + one view: + +- **`employees`** — id, name, department, supervisor, **notes** +- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging +- **`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) +- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee + +--- + +## Amendable Fields + +Point values, violation type, and incident date are **immutable** after submission. The following fields can be amended: + +| Field | Notes | +|-------|-------| +| `incident_time` | Time of day the incident occurred | +| `location` | Where the incident took place | +| `details` | Narrative description | +| `submitted_by` | Supervisor who submitted | +| `witness_name` | Witness on record | + +--- + +## Roadmap + +### ✅ Completed + +| Phase | Feature | Description | +|-------|---------|-------------| +| 1 | Container scaffold | Docker multi-stage build, Express server, SQLite schema | +| 1 | Base violation form | Employee fields, violation type, incident date, point submission | +| 2 | Employee intelligence | Live CPAS standing badge and 90-day count shown before submitting | +| 2 | Prior violation highlighting | Violation dropdown annotates types with 90-day recurrence counts | +| 2 | Recidivist auto-escalation | Points slider auto-maximizes on repeat same-type violations | +| 2 | Violation history | Per-employee history list with resolution status | +| 3 | PDF generation | Puppeteer/Chromium PDF per violation, downloadable immediately post-submit | +| 3 | Prior-points snapshot | `prior_active_points` captured at insert time for accurate historical PDFs | +| 4 | Company dashboard | Sortable employee table with live tier badges and at-risk flags | +| 4 | Stat cards | Summary counts: total, clean, active, at-risk, highest score | +| 4 | Tier crossing warning | Pre-submit alert when new points push employee to next tier | +| 4 | Employee profile modal | Full history, negate/restore, hard delete, per-record PDF download | +| 4 | Negate & restore | Soft-delete violations with resolution type + notes, fully reversible | +| 5 | Employee edit / merge | Update employee name/dept/supervisor; merge duplicate records without losing history | +| 5 | Violation amendment | Edit non-scoring fields with field-level audit trail | +| 5 | Audit log | Append-only log of all system writes; filterable panel in the dashboard | +| 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 | + +--- + +### 📋 Proposed + +#### Reporting & Analytics +- **Violation trends chart** — line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns vs. individual incidents +- **Department heat map** — grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk +- **CSV / Excel export** — bulk export of violations or dashboard data for external reporting or payroll integration + +#### Employee Management +- **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 + +#### Notifications & Escalation +- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed +- **Scheduled summary digest** — weekly email to supervisors listing their employees' current standings and any approaching tier thresholds +- **At-risk threshold configuration** — make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment + +#### Infrastructure & Ops +- **Multi-user auth** — simple login with role-based access (admin, supervisor, read-only); currently the app has no auth and is assumed to run on a trusted internal network +- **Automated DB backup** — cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule +- **Dark/light theme toggle** — the UI is currently dark-only; a toggle would improve usability in bright environments + +--- + +*Proposed features are suggestions based on common HR documentation workflows. Priority and implementation order should be driven by actual operational needs.* -- 2.52.0 From 57358dfd211168198e1b9f7c90ad69bcc6a3f819 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:28:45 -0600 Subject: [PATCH 05/54] feat: add Toast notification system - ToastProvider context with useToast hook - Supports success, error, info, and warning variants - Auto-dismiss with configurable duration (default 4s) - Slide-in animation with progress bar - Stacks up to 5 toasts, oldest dismissed first - Consistent with dark theme UI --- client/src/components/ToastProvider.jsx | 145 ++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 client/src/components/ToastProvider.jsx 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 => ( +
    + +
    + ))} +
    +
    + ); +} -- 2.52.0 From c4dd658aa7a5820df122baad7fc3ee8514eec75c Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:29:05 -0600 Subject: [PATCH 06/54] feat: wrap App with ToastProvider for global notifications --- client/src/App.jsx | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) 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)} />} -
    + ); } -- 2.52.0 From 725dfa29638d61c6e0b90a6a5e4a3982106abdcf Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:30:29 -0600 Subject: [PATCH 07/54] feat: add acknowledgment signature fields + toast notifications to ViolationForm - New "Employee Acknowledgment" section with acknowledged_by name and date - Replaces blank signature line on PDF with recorded acknowledgment - Toast notifications for submit success/error, PDF download, and validation warnings - Inline status messages retained as fallback --- client/src/components/ViolationForm.jsx | 44 +++++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) 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 && ( -- 2.52.0 From 8e06c9d576c4c4f7081623622e3b58f31d25aa1a Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:30:47 -0600 Subject: [PATCH 08/54] feat: add acknowledged_by and acknowledged_date columns to violations schema --- db/schema.sql | 2 ++ 1 file changed, 2 insertions(+) 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 ); -- 2.52.0 From 8944cc80e0a6037398d50de3547a63ff29b9df00 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:31:05 -0600 Subject: [PATCH 09/54] feat: add auto-migration for acknowledged_by/acknowledged_date columns --- db/database.js | 2 ++ 1 file changed, 2 insertions(+) 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); -- 2.52.0 From b4edcdc945c8061693ce54f38ac7110fff6f8f96 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:32:15 -0600 Subject: [PATCH 10/54] feat: accept acknowledged_by/acknowledged_date in violation creation and amendment - POST /api/violations now accepts acknowledged_by and acknowledged_date - Both fields added to AMENDABLE_FIELDS whitelist for post-submission edits - Acknowledgment data persisted to DB and passed through to PDF generation --- server.js | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) 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) { -- 2.52.0 From 114dbb1166794555de91a58f9edf61127a418546 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:39:01 -0600 Subject: [PATCH 11/54] refactor: load logo from disk instead of hardcoded base64 + add acknowledgment signature rendering - Reads mpm-logo.png from filesystem at startup, converts to base64 data URI dynamically - Removes massive hardcoded LOGO_B64 constant (~5KB of source code eliminated) - Falls back gracefully if logo file is not found (dev environments) - Tries both dist/ and public/ paths for dev vs production compatibility - Adds acknowledgment rendering: if acknowledged_by is set, shows filled name/date instead of blank signature lines, with green "Acknowledged" badge on section header - Blank signature lines still shown when acknowledgment is not provided --- pdf/template.js | 374 +++++++++++++----------------------------------- 1 file changed, 103 insertions(+), 271 deletions(-) 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 = 'iVBORw0KGgoAAAANSUhEUgAAAGsAAACbCAYAAABlLWUVAAAQAElEQVR4AexdC3AW1RW+f8CgQqsiCLUVLBjEKuADRFBQsCDgY6zAWAq2FijtjFL7tFUcRtvRqeNYW6Y+Z2oVHOigtCKPFDCgSAsICAFJEBTRVEDegYRHXtdz/nCT/Tf7uOfc/fffTXZnD3cf53zn3O/ec/fs/4ckTyRbbBggDZbcsbhN5YaOMpFgOKDOEq3BqtxY8FnF+gtkZfm9J6VMiUSC4QA5RTn58fipOgPnOVgHi0d+C8FkbXkXHbBEh8dAzZFlM5BnP2vXwTq27gKZX7WhTEoBmZRIGDwg58c2XvGu26A5DtZRGCgYI5EITFJgLlQeavYNRv7BbZO9yWCVv99JCnguJZKCkcqdHN3Q7ZB9tDIGKz1QoBHqTEr8CUe+ayvP27/twa8BPQ17xmCFsS4nPiBhYXR0eDij/NWjDSMFBw2DdWRtZ4CAtBfNVeLZr/pxgZGCPT1Ycu/MtjBSzukISsk9yIYc8gCu03t6sI7serAiGSngI6Kz8vAaXPWESA9WUDGed93eVCKNHLTp8XrXoLiFqSTyTh1Y0VNCqc6VVmdf8157GCQUBEykkYGz2w/6HHlB4fKr7BA1r2LHD0rxgCPnD9iTOqf3osEc25Zmg1y1anfl09x+y4otnfIk5ClHTnVd0JbruKXandur8DccrtHm0NbRJfDMSgF3dLnwwr7HwTDZiQx0GLgHyIad+IokayvaszKrw8Dd6I0YZqKuGMBM4QhkloJI2rAY6Hg9b7JDZul9kSYtFWNYnWrOfqx86h7nQX1Bfh9uziSG1TcO78kyGNboBOAHlkH43AuGWRIkAL8tHoLCt9KFzMLCTl8kPLtaPNOBEKDPuYAyH3knZ5ZItkAYkISVDHXRaVJgIAs5EBgrcmGXJ3BZI4hE3Rx0rtm5RB4JgryTM6vZkZajDlEzC8MkP7MkekHL5iuh9Ax5pApUg9TYsIqh2iT65gykBGQW9eMmkWwBMCDheUUTUf+1fgC+E4gQGCAXGDgbQoir2bvARz9JIBPJpXuzZzGsDgL5pNcmiAueWclng8BD6LuEtKIKfRkMvVvN0yGMlaAKlO4pYIMqYJLshgxQOU9h6Z4sgyIHG3UJRH3ILFqkaORpkdzMCgPIOxQYtJdiAd+tiGQzZkBCNUgRAbwnBYbIzUYtLlAf3rMgWDyiCJgkuyEDFL5P6yaZZcg51/w0/6TynfzMwnWWG2Bi18gA8kgVcjXY6C45CpsByKzkPSts0tGfhHWQKpBZ9DdpdJaIKQN03pMCw4BzE1NILFJxgfqsZVBKCRlpEmrLtt1W2B0opD9+4D0L0hHepgVBPiosqG3ZdBv2nsC1dVxYyyCm5O4tf7zMMOQWaV66+BKsKwRySBX2clZe9mpJi2TboNM7igZ8YGCOX5HQPsiVkMJKShYVyNLC3hUmAbQUW+Sq+uSBqxR3nJadWYpkWXeiLQaCoq4lbT0DB3e+Nhx5Qam/YvYvsxp0rmS2LiyQ2RSzrjpbf/nhE9dnK+a9JY8tkfBgCkrYBQbEwHpImtg50212df+uf6wyiSlMW1bpLuC5lRMxGxdn61z1heE3h5kFSynQR5mZWxb0QHWwCmb/cGGPOgSMiwT6zApqbfbCCWaY6lHq6kTKy1fU7hlXg/XdDu/fA5++0TM8b9HyBJnFf8+SsO6GLV9snsb+LW5W6ovnXwqfz8Wr77HLLCvhLe04VgWGKgTKNvzyEtOBUlhxamO3DOKye6CscIfJYG18s2fslkDsN7xnQbfjNL1UrBA2e1cYMWtjuQwix6XL73qCO1hoH0eBAgO+fBQ0kbWyj6m06zCwu5Occ+HwvwuNeE6Ulz4kGFvxwmv3CQ181HGKD6+R++7AF+JTBZ5Z8EkCTDNJkGvGfLTZVHoMenmnk3TvP2OybiyCsdVUHeuog3/190pTTvHhNdO+o71ODHYdyCxGj7Ns0rH7hF46LkqK7vyfjl5z0YHMor8YZrvzXfpM+1BqvHAfP7J9ACWW9fO+I3Vwz+0yrjcFl6OrE4ddh1VgcIKj2sCqLHSEgquDhzrd+07fQsHl6KIfqvBKd050RJt+o0sg5cHIp0cb37puJ2jp7T5YDbNDD81MSzcWi15kMwuZsMTZwKP9WnXV0W+jrp9smN9/u93W6bzfmBIsjf3gjO87+fa7FslnlmLCvma7nSt9r7amqqLAzd563QsjyHtWn7rHkawGFSn9x27VmuVrX78cJ6Uya7YtZBb9PStMNiQMg454xbRh4dBdOhi6k8PLl+49nXjsOpBZOHmpohuSuV4qdUa10PrEQbhu1cf3dRWGGCLwjcp5SkS6wBCw9R+7KR+Sy7XAUPdWz70CD8Gi6Y43/CTV6sxQ/xCOXzxO9yO/DCL19uXA7Rx17bJmXr9SN33r9f6j14f6J6asvnWPI78MIvl5rdseE8xlrK7mZE+urcjqxlkGId90R1bpZbUPDuDXjV77deXbq/3vP3tBbzIBvPTVvbPOuXR1plX2z5RvSguZlf3AcuWheOndi3R8XzXijYE6ernWicUzC0nKP7PzEzqzEHWVHDtYMopqo2z92qO7V3Tw0/G6rxOXXQcyi752egWRrXv97lw2TWg8t1bN6W1ZCv37dn7XUeMFY9v87gP7GWYWE//YhK2/Ocmsze/er/V9lbBt9pnmdo5mGwvvXu5233r9soFPzkZ9qiAG1caqj/ZUycl71pEvVm62Bq57XHD5U+0wbXxFyvxjh0uH+OrpOrbpbSqadA9i2y6TTtG+qcCnSYDidh2+IoF0hC/6BEUA0GhHXwyAzn1uqdSJ873ZV57S0Rs07q5WjDDE0b3rZ6bxOcbKBjkgSk4yC2fOobKVrG9j0TYoSaUerVPcUVrln2Jj11UYlBYKDDtMOOdbVk4t5ni6cXwxLAUcy2BsVs7p91EwSHQUKDBSQkI6UoTuJtNC+cq8qn+m7E1a7qDX1Vb3UH71I26qqTAobc6WQUz/TUVThjfthv8VtDUVfy/OGla/zhp6V604usdQYAC4rrbSAxOj/TTO4d3vL+HgDJmwCZYDsDyNI4htSuSxfkPOillXygxfwmAjxox+c5pZGC+3u2jLlZsmfNCa49fuj4OhbOxYOuc5fWbhev3F9vnDVAcoLdpyheLHqmv3Z71HPbZj6ZznrBpUndu25tGl6pjS3vzDjayqsM3ZnT6j+FG6RTOvwsmvTnPSQmbBWzOEIQliGqndFxfPjqNzfsOY/1zM8eeEzcFRNk54ftcgs3CCUkW55LaZ/la9cdvPOEitWrf9VNg+7PQ/F8wtM2aR9isMNic872uRyKwTFbuf5/R6yPhV3fxmo/V+p4uHP8Lxs+yVq6UVRx1zsJSNwqC0kFnKvPm3vW780+Nx7iVkFryy5OgTDGnx+8nGF3/BIfK8TgMesOJ4HXPwpZzbyg2Tg6ds3DC9ruf8PQvqGnzfEx9veukZ1RFK23fkszMUhlfbre99XSi4SnfpK0/WuOEqHU7rhul1vcUsg5f0mljGITVKNrAM5r50lzCdUJbPHvY0h5zrb33+G2jvJRzc4qJp44LGVHF44brdg8zyLhdFukS16wjDzY5Xf1518vCvOMDtLrh2r3CMsx53xMQNeCCo257PlsBX/mjqJlREq74bpvv1SGUWzihrdyjHaOsmFByrrhuuum7VpR4rDEprKTBgOQSPsCKlH/heLagZ7V7YHyx/eAYHfMTEdWe44XLw1i35+etueOo6B1fZKAxKC1+RQNpBCS0oojxyWw9fe3cum8qBTaVSNU59GDVpPXSQjri/bPUYJ7yMa3TYRgsPDjJ8WPQil1k40xp7RDtCW7vQEBq17ThO543a9CMnPL9rkXtm4Rq+7LVb5tG7L8Stk9dl/GbONme238XBWTpr+A6Mw0842MrGD9vpPlSDyjw67akTh+4KIprvTlii9Z/D7b6gKjX+FXl2zCDOIbOi8XGThLXZKtzOBY1hxbMfc2NEOzuWznkkMws7s7bwgbnYUuX2Ke+nC4qul935LNUW9Re8dC0+OvAwcgKZBSU7hCcJYtoLHV9ffr56LNdPKi//ZO9BD9/PsdeJTelw8JWNwqC0kFk4EamiXHJbXX88/NsmrzqLZ4lWurGhHupzBe1pEtnMwhm36OWha7hUeNq53Jz/Yn/HLxkxlibigqF7uQme38oGwJF8z4K4BUp1VWV/iDG0PU0geEPfvoIKoMvd0ZwkoBzxZRCXCS4dNDtIKQYXNB+Z2tg3mkR6GcSZ/s68SYWZnczO2ZsvDKhFfxQxiYTiR+nCbDJxmX3bw/u2jsi+l3h4gMyK5kuxtLwkh0Gl1Z/usUlcuj6sepEuMOCZmi405r9083YTYvxs//XcAKl86bZ+mH73df0oPcSDr0igUVd0WzAx2nX9nNarqT5eYOTPx5g8UhBX2sYH1/M2YKRnomaL/mKRWdgfz44b3FyzZPowxOeIgVvKODXoxuKZJeH5tXTOPaz/wOBHaNmOt5ciPkf8sL3uc/xxqkGvGLJ2r/zgJ6z/GpS1gHIADJkVzQ9yJaxLdgman80r/zbd7oNybhIPxY/ShcyivUWL9I98CcON4zMl/v3C8IOGjjPMtxXPeUyk+8OLRxhtdJ+xKTAg0URVdWV7I35sxohpIjY40inHbyxK94ZyCHtIosRdefGs7y/OwEVsqrjD+9+h+gJ9eGZF/xMMCdWgkgWvjtX6HYJ+bB099P+RCpPV+jnwuU/2CXixWgZhcomKI3tGQdzGO2IZCUwgkyDIvsEfFBgmLuNpO+evNyBXsQs+dssgLh+LXvvRehOmEcNcTCLA1yXq40eIWGZW+YGd15hRpWvtpZfyupmFeykBmYWjTBNhuElYhEyFG8Lsv9xYbepb2XNjQDuFQWljV2DAOKcr7rnPjXwLO02VOlnXWmGYtFS/dn2qb7SH9yxIZ6g0BEXQ0kQovlx0q05W3s4KwQWP1H/AkCAs/8oI7QmC/mKbWTgzVb9121l/HizRLijR9eukx4mB9cya+fRgaSLIWBBCjSEIn1YMqn+rvhVH5xgHPJbVIAbe0gQHFDKLWu8n+hKeNWHLRQU3PZKX36ZtqD+i3NIyIqj+DrnjD4/njZu6eADnYZfYwLspjERYPICr+k8wspXSCW5wj4yGwep66dD7RFhTJPFDTskf//YdeBkW9Zk19I7pzyUckjkMbX5jVqE0lO4Xdes3SRj9PEJKiMReiIA5mPhgfVYJ2BoGa9iYp16WkF6JQIZFiAcYo4a9YbDwyqTfrcD0wMNEIsCAfTwyBgvjQwWZg5e+xGdm5YjjgONhlSaDhTcn/355kmFIRI7EjX/HwcIY0UBGaO1uCbF0/mafucg78u8kroOFyj95aHkKBcYstDI1pr6M+UGeb7vnmbuRdzfxHCxlNAUGDUWcLktl8kwj/80xO2fIZX5+28PIK4rQ2LQGS+FMeagohfLTh4tSKHD97UQEiQPkDQV5vPfXC0g/Dv4V3Z9eXgAAAAVJREFUAAAA///yqks2AAAABklEQVQDAL3XqznDSjO2AAAAAElFTkSuQmCC'; +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) {
-- 2.52.0 From ecd38100501c4b44fd99079f97a47eeb51fc05c8 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:40:36 -0600 Subject: [PATCH 12/54] feat: add toast notifications to EmployeeModal for all actions - Toast success/error on PDF download, negate, restore, hard delete - Toast success on employee edit and violation amendment via modal callbacks - Error details from API responses included in error toasts --- client/src/components/EmployeeModal.jsx | 67 +++++++++++++++++-------- 1 file changed, 45 insertions(+), 22 deletions(-) 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(); }} /> )} -- 2.52.0 From 66f59dead3b43670aa600f7225f8fc478a432491 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:44:22 -0600 Subject: [PATCH 13/54] docs: update README for acknowledgment signature field, toast notifications, and PDF logo refactor - Moved acknowledgment signature field from Proposed to Completed (Phase 7) - Added toast notification system to Completed (Phase 7) - Updated Violation Form features with acknowledgment section and toasts - Updated Employee Profile Modal features with toast notifications - Added Toast Notification System as standalone feature section - Updated PDF Generation section: logo loaded from disk, ack rendering - Updated Amendable Fields table with acknowledged_by and acknowledged_date - Updated Database Schema section with new violation columns - Updated Project Structure tree with ToastProvider.jsx and template.js description - Updated API reference for POST /api/violations to note new fields --- README.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) 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 -- 2.52.0 From da602f69af738fb697d26f4991958cd909876782 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 21:46:30 -0600 Subject: [PATCH 14/54] docs: update ReadmeModal admin guide for acknowledgment signature and toast notifications - Added acknowledgment signature workflow to "Logging a Violation" section (step 7) - Added toast notification step to violation logging workflow (step 9) - Updated Violation History section: amend now includes acknowledged-by/date fields - Added PDF acknowledgment rendering note to Violation History - Added "Toast Notifications" as standalone feature section - Updated Amendable Fields to include acknowledged_by and acknowledged_date - Updated Immutability Rules table with ack fields - Moved acknowledgment signature and toast system to Shipped in roadmap - Removed acknowledgment signature from Near-term (already shipped) --- client/src/components/ReadmeModal.jsx | 39 ++++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) 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('
${inline(c)}
${inline(c)}
${active ? '▶ ' : ''}${range}${active ? '\u25B6 ' : ''}${range} ${t.label} - ${active ? ' ← Current' : ''} + ${active ? ' \u2190 Current' : ''}
'); 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); -- 2.52.0 From 979e9724e002f0b180a8314fa065eec50fcc66bd Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 22:01:35 -0600 Subject: [PATCH 15/54] fix: score endpoint now returns total_violations and negated_count The /api/employees/:id/score endpoint previously only returned data from the active_cpas_scores view (active_points + violation_count for the 90-day window). The EmployeeModal score cards reference total_violations and negated_count which were undefined, causing blank displays. Now queries the violations table directly for all-time totals alongside the rolling 90-day active data. --- server.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index 59ed7d3..f4c59cc 100755 --- a/server.js +++ b/server.js @@ -128,10 +128,29 @@ app.patch('/api/employees/:id/notes', (req, res) => { res.json({ id, notes: newNotes }); }); -// Employee score (current snapshot) +// Employee score (current snapshot) — includes total violations + negated count app.get('/api/employees/:id/score', (req, res) => { - const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id); - res.json(row || { employee_id: req.params.id, active_points: 0, violation_count: 0 }); + const empId = req.params.id; + + // Active points from the 90-day rolling view + const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId); + + // Total violations (all time) and negated count + const totals = db.prepare(` + SELECT + COUNT(*) AS total_violations, + COALESCE(SUM(negated), 0) AS negated_count + FROM violations + WHERE employee_id = ? + `).get(empId); + + res.json({ + employee_id: empId, + active_points: active ? active.active_points : 0, + violation_count: active ? active.violation_count : 0, + total_violations: totals ? totals.total_violations : 0, + negated_count: totals ? totals.negated_count : 0, + }); }); // ── Expiration Timeline ────────────────────────────────────────────────────── -- 2.52.0 From 5d835e6b911cf4f1e6edac2bf6a93d6d8cc1f3b4 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 23:13:26 -0600 Subject: [PATCH 16/54] feat: add shared DEPARTMENTS constant --- client/src/data/departments.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 client/src/data/departments.js diff --git a/client/src/data/departments.js b/client/src/data/departments.js new file mode 100644 index 0000000..9ed3cfa --- /dev/null +++ b/client/src/data/departments.js @@ -0,0 +1,9 @@ +export const DEPARTMENTS = [ + 'Administrative', + 'Business Development', + 'Design and Content', + 'Executive', + 'Implementation and Support', + 'Operations', + 'Production', +]; -- 2.52.0 From 0f31677631a959ea55178c4b3f03a34578fc0aab Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 23:14:40 -0600 Subject: [PATCH 17/54] feat: replace department text input with preloaded select dropdown --- client/src/components/ViolationForm.jsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx index 3367803..2ca944b 100755 --- a/client/src/components/ViolationForm.jsx +++ b/client/src/components/ViolationForm.jsx @@ -6,6 +6,7 @@ import CpasBadge from './CpasBadge'; import TierWarning from './TierWarning'; import ViolationHistory from './ViolationHistory'; import { useToast } from './ToastProvider'; +import { DEPARTMENTS } from '../data/departments'; const s = { content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' }, @@ -171,12 +172,21 @@ export default function ViolationForm() { )}
- {[['employeeName','Employee Name','text','John Doe'],['department','Department','text','Engineering'],['supervisor','Supervisor Name','text','Jane Smith'],['witnessName','Witness Name (Officer)','text','Officer Name']].map(([name,label,type,ph]) => ( + {[['employeeName','Employee Name','John Doe'],['supervisor','Supervisor Name','Jane Smith'],['witnessName','Witness Name (Officer)','Officer Name']].map(([name,label,ph]) => (
- +
))} +
+ + +
-- 2.52.0 From d8793000fc69fa2089318fea2fd764e52c758fef Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 23:15:15 -0600 Subject: [PATCH 18/54] feat: replace department text input with preloaded select dropdown --- client/src/components/EditEmployeeModal.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/components/EditEmployeeModal.jsx b/client/src/components/EditEmployeeModal.jsx index a438e1b..1d71ed4 100644 --- a/client/src/components/EditEmployeeModal.jsx +++ b/client/src/components/EditEmployeeModal.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { DEPARTMENTS } from '../data/departments'; const s = { overlay: { @@ -133,7 +134,12 @@ export default function EditEmployeeModal({ employee, onClose, onSaved }) {
Full Name
setName(e.target.value)} />
Department
- setDepartment(e.target.value)} placeholder="Optional" /> +
Supervisor
setSupervisor(e.target.value)} placeholder="Optional" />
-- 2.52.0 From 76f972562b376e0b64072fb0e0264c3229988d46 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 23:27:25 -0600 Subject: [PATCH 19/54] feat: add stakeholder demo landing page with synthetic data --- demo/index.html | 750 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 demo/index.html diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..50f4c60 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,750 @@ + + + + + +CPAS Tracker — Demo Preview + + + + + + + +
+
+ DEMO ENVIRONMENT — Simulated data for stakeholder preview only — Not connected to live database +
+
+ + + + +
+ + +
+ +
+
Corrective Performance Action System
+

Employee Compliance Dashboard

+

Real-time visibility into workforce disciplinary standing. Track violations, monitor tier escalations, and generate signed documentation — all in one place.

+
+
47
Total Employees
+
+
23
Active Violations (90d)
+
+
3
At-Risk (Tier 3+)
+
+
91%
In Good Standing
+
+
+ +
+
+
New This Week
+
6
+
+2 vs prior week
+
+
+
+
Tier 3+ Employees
+
3
+
Requires attention
+
+
+
+
PDFs Generated
+
18
+
This month
+
+
+
+
Expiring (30d)
+
9
+
Points rolling off
+
+
+
+ +
+ +
+
+ Employee Roster + 47 total +
+
${inline(c)}
${inline(c)}
+ + + + + + + + + + + +
EmployeeDeptStanding
Marcus T.
Operations
22 pts — Tier 4
Janelle R.
Production
17 pts — Tier 3
Devon H.
Operations
15 pts — Tier 3
Priya S.
Impl & Support
12 pts — Tier 2
Carlos M.
Production
7 pts — Tier 1
Aisha W.
Administrative
5 pts — Tier 1
Tom B.
Design & Content
2 pts — Elite
Sandra K.
Business Dev
0 pts — Elite
+ + + +
+
+ Recent Violations + Last 7 days +
+
+
+
+
+
Marcus T. ★ REPEAT
+
Unauthorized Absence — Operations
+
Mar 6·D. Williams
+
+
+5
+
+
+
+
+
Janelle R.
+
Insubordination — Production
+
Mar 5·K. Thompson
+
+
+4
+
+
+
+
+
Devon H.
+
Tardiness (3×) — Operations
+
Mar 4·D. Williams
+
+
+3
+
+
+
+
+
Carlos M.
+
Cell Phone Policy — Production
+
Mar 3·K. Thompson
+
+
+2
+
+
+
+
+
Priya S.
+
Dress Code Violation — Impl & Support
+
Mar 2·M. Johnson
+
+
+1
+
+
+
+
+
Aisha W.
+
Late Return from Break — Administrative
+
Mar 1·S. Martinez
+
+
+1
+
+
+
+ + +
+ +
+
+ Violations by Department + 90-day window +
+
+
Operations
8
+
Production
6
+
Impl & Support
4
+
Administrative
2
+
Business Dev
1
+
Design & Content
1
+
Executive
0
+
+
+ + +
+
+ Audit Log + System activity +
+
+
03/06 2:14p
Violation #41 created — Marcus T.
+5 pts
+
03/06 2:15p
PDF generated for Violation #41
+
03/05 9:40a
Violation #40 created — Janelle R.
+4 pts
+
03/04 11:20a
Employee Devon H. record updated
+
03/04 8:55a
Violation #39 created — Devon H.
+3 pts
+
03/03 3:30p
Violation #38 amended — Carlos M.
−1 pt
+
03/02 1:05p
Duplicate record merged — R. Johnson
+
+
+
+ + +
+
+
CPAS Tier Scale
+
+
+
0–4
Elite
Standing
+
5–9
Tier 1
Realignment
+
10–14
Tier 2
Admin Lockdown
+
15–19
Tier 3
Verification
+
20–24
Tier 4
Risk Mitigation
+
25–29
Tier 5
Final Decision
+
30+
Tier 6
Separation
+
+
+ + +
System Capabilities
+
+
Repeat Offense Detection
Automatically flags prior violations for the same type and escalates point recommendations per recidivist policy.
+
📄
One-Click PDF Generation
Generates signed, professional violation documents instantly — with or without employee acknowledgment signatures.
+
🔀
Duplicate Record Merge
Consolidate duplicate employee records while preserving full violation history under the canonical profile.
+
📊
90-Day Rolling Window
Points automatically expire after 90 days. Active standing reflects only the current compliance window.
+
🏷️
Tier Escalation Warnings
Real-time alerts when a new violation would push an employee across a tier boundary before you submit.
+
🗂️
Full Audit Trail
Every create, amendment, merge, and PDF generation is logged with timestamp and operator attribution.
+
+ + + + +
+ +
+
+ ⚠ DEMO VIEW — Form fields shown with sample data. Submission is disabled in demo mode. +
+
+ +
+
+
Employee Information
+
+
Quick-Select Existing Employee:
+
+ Marcus Thompson — Operations +
+
+ Current Standing: + 22 pts — Tier 4 · Risk Mitigation + 4 violations in last 90 days +
+
+
+
Employee Name:
Marcus Thompson
+
Department:
Operations
+
Supervisor Name:
D. Williams
+
Witness Name (Officer):
Officer Name
+
+
+ +
+
Violation Details
+
+
+
Violation Type:
+
+ Unauthorized Absence ★ 2x in 90 days +
+
+ Unauthorized Absence ★ Repeat — 2x prior
+ Absence from scheduled work without prior approval or acceptable documentation.
+ Chapter 4, Section 4.1 — Attendance & Punctuality +
+
+ Repeat offense detected. Point slider set to maximum (5 pts) per recidivist policy. Adjust if needed. +
+
+
Incident Date:
2026-03-06
+
+
+ ⚠ Tier escalation warning: Adding 5 pts will bring Marcus to 27 pts (Tier 5 — Final Decision). This is one tier below Separation. Review carefully. +
+
+
CPAS Point Assessment
+
Unauthorized Absence: 3–5 Points
+
+
5 Points
+
Adjust to reflect severity and context
+
+
+ +
+
Employee Acknowledgment
+
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
+
+
Acknowledged By:
Employee's printed name
+
Acknowledgment Date:
yyyy-mm-dd
+
+
+ +
+ + +
+
+ +
+ + + +
+ +
DEMO BUILD — All data synthetic — Not for operational use
+
© 2026 MPM
+
+ + + + -- 2.52.0 From 7ef00796bd4854acfe2b3e2984a203595fcd00a2 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 23:59:00 -0600 Subject: [PATCH 20/54] feat: serve demo/ folder as static route at /demo Adds express.static middleware for the demo/ directory mounted at /demo, placed before the SPA catch-all so /demo/index.html resolves to the standalone stakeholder demo page instead of the React app. --- server.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server.js b/server.js index f4c59cc..d1dc66b 100755 --- a/server.js +++ b/server.js @@ -11,6 +11,11 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); +// ── Demo static route ───────────────────────────────────────────────────────── +// Serves the standalone stakeholder demo page at /demo/index.html +// Must be registered before the SPA catch-all below. +app.use('/demo', express.static(path.join(__dirname, 'demo'))); + // ── Audit helper ───────────────────────────────────────────────────────────── function audit(action, entityType, entityId, performedBy, details) { try { -- 2.52.0 From 575c4c57fd663b732702189761f6615cf4796a6d Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:11:18 -0600 Subject: [PATCH 21/54] fix: add demo/ folder to Dockerfile COPY so /demo route is served in container --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index c3bebec..18091ea 100755 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ COPY server.js ./ COPY package.json ./ COPY db/ ./db/ COPY pdf/ ./pdf/ +COPY demo/ ./demo/ COPY client/public/static ./client/dist/static RUN mkdir -p /data EXPOSE 3001 -- 2.52.0 From 232814db93adae0e78c1acbc725351b6a19468f9 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:19:59 -0600 Subject: [PATCH 22/54] feat: enhance demo footer with copyright, Gitea link, and live dev-time ticker --- demo/index.html | 209 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 176 insertions(+), 33 deletions(-) diff --git a/demo/index.html b/demo/index.html index 50f4c60..e43ff4a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -10,27 +10,27 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --gold: #d4af37; - --gold-lt: #ffdf8a; - --gold-dk: #a88520; - --bg: #050608; - --bg-nav: #000000; - --bg-card: #111217; - --bg-section:#181924; - --border: #222; - --border-lt: #2a2b3a; - --text: #f8f9fa; - --text-muted:#9ca0b8; - --text-dim: #d1d3e0; - --green: #28a745; - --green-bg: #d4edda; - --yellow: #856404; - --yellow-bg: #fff3cd; - --red: #d9534f; - --red-bg: #f8d7da; - --red-dk: #721c24; - --red-dk-bg: #f5c6cb; - --sep: #721c24; + --gold: #d4af37; + --gold-lt: #ffdf8a; + --gold-dk: #a88520; + --bg: #050608; + --bg-nav: #000000; + --bg-card: #111217; + --bg-section: #181924; + --border: #222; + --border-lt: #2a2b3a; + --text: #f8f9fa; + --text-muted: #9ca0b8; + --text-dim: #d1d3e0; + --green: #28a745; + --green-bg: #d4edda; + --yellow: #856404; + --yellow-bg: #fff3cd; + --red: #d9534f; + --red-bg: #f8d7da; + --red-dk: #721c24; + --red-dk-bg: #f5c6cb; + --sep: #721c24; } html { scroll-behavior: smooth; } @@ -407,8 +407,94 @@ .feature-desc { font-size: 12px; color: var(--text-muted); line-height: 1.6; } /* ── FOOTER ── */ - footer { border-top: 1px solid var(--border); padding: 24px 40px; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text-muted); font-family: 'DM Mono', monospace; } - .footer-brand { font-family: 'Syne', sans-serif; font-size: 13px; font-weight: 700; color: var(--text-dim); } + footer { + border-top: 1px solid var(--border); + padding: 20px 40px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + font-size: 11px; + color: var(--text-muted); + font-family: 'DM Mono', monospace; + background: var(--bg-nav); + } + .footer-left { + display: flex; + align-items: center; + gap: 18px; + } + .footer-brand { + font-family: 'Syne', sans-serif; + font-size: 13px; + font-weight: 700; + color: var(--text-dim); + } + .footer-copy { + color: var(--text-muted); + font-size: 11px; + } + .footer-gitea { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + text-decoration: none; + padding: 4px 10px; + border: 1px solid var(--border-lt); + border-radius: 5px; + transition: border-color 0.2s, color 0.2s; + font-size: 11px; + } + .footer-gitea:hover { + border-color: var(--gold-dk); + color: var(--gold-lt); + } + .footer-gitea svg { + width: 14px; + height: 14px; + fill: currentColor; + flex-shrink: 0; + } + .footer-right { + display: flex; + align-items: center; + gap: 18px; + } + .footer-ticker { + display: flex; + align-items: center; + gap: 8px; + background: rgba(212,175,55,0.06); + border: 1px solid rgba(212,175,55,0.2); + border-radius: 5px; + padding: 4px 12px; + } + .footer-ticker-label { + font-size: 10px; + color: var(--text-muted); + letter-spacing: 0.5px; + text-transform: uppercase; + } + .footer-ticker-time { + font-family: 'DM Mono', monospace; + font-size: 12px; + color: var(--gold); + font-weight: 500; + letter-spacing: 1px; + } + .footer-ticker-dot { + width: 5px; height: 5px; border-radius: 50%; + background: var(--gold); + animation: pulse 2s infinite; + flex-shrink: 0; + } + .footer-divider { + width: 1px; + height: 16px; + background: var(--border-lt); + } /* ── ANIMATIONS ── */ .fade-in { opacity: 0; transform: translateY(16px); animation: fadeUp 0.5s ease forwards; } @@ -429,6 +515,8 @@ .hero h1 { font-size: 26px; } nav { padding: 0 16px; } .main { padding: 0 12px 60px; } + footer { padding: 16px 20px; flex-direction: column; align-items: flex-start; } + .footer-right { flex-wrap: wrap; } } @@ -531,7 +619,7 @@
-
Marcus T. ★ REPEAT
+
Marcus T. ☆ REPEAT
Unauthorized Absence — Operations
Mar 6·D. Williams
@@ -643,8 +731,8 @@
Repeat Offense Detection
Automatically flags prior violations for the same type and escalates point recommendations per recidivist policy.
📄
One-Click PDF Generation
Generates signed, professional violation documents instantly — with or without employee acknowledgment signatures.
-
🔀
Duplicate Record Merge
Consolidate duplicate employee records while preserving full violation history under the canonical profile.
-
📊
90-Day Rolling Window
Points automatically expire after 90 days. Active standing reflects only the current compliance window.
+
📀
Duplicate Record Merge
Consolidate duplicate employee records while preserving full violation history under the canonical profile.
+
🕊
90-Day Rolling Window
Points automatically expire after 90 days. Active standing reflects only the current compliance window.
🏷️
Tier Escalation Warnings
Real-time alerts when a new violation would push an employee across a tier boundary before you submit.
🗂️
Full Audit Trail
Every create, amendment, merge, and PDF generation is logged with timestamp and operator attribution.
@@ -656,7 +744,7 @@
- ⚠ DEMO VIEW — Form fields shown with sample data. Submission is disabled in demo mode. + ⚡ DEMO VIEW — Form fields shown with sample data. Submission is disabled in demo mode.
@@ -688,10 +776,10 @@
Violation Type:
- Unauthorized Absence ★ 2x in 90 days + Unauthorized Absence ☆ 2x in 90 days
- Unauthorized Absence ★ Repeat — 2x prior
+ Unauthorized Absence ☆ Repeat — 2x prior
Absence from scheduled work without prior approval or acceptable documentation.
Chapter 4, Section 4.1 — Attendance & Punctuality
@@ -702,7 +790,7 @@
Incident Date:
2026-03-06
- ⚠ Tier escalation warning: Adding 5 pts will bring Marcus to 27 pts (Tier 5 — Final Decision). This is one tier below Separation. Review carefully. + ⚡ Tier escalation warning: Adding 5 pts will bring Marcus to 27 pts (Tier 5 — Final Decision). This is one tier below Separation. Review carefully.
CPAS Point Assessment
@@ -733,9 +821,28 @@
- -
DEMO BUILD — All data synthetic — Not for operational use
-
© 2026 MPM
+ +
-- 2.52.0 From 7e1164af13d93379a2ce8de9dae6103b16a8bf07 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:21:30 -0600 Subject: [PATCH 23/54] feat: add footer with copyright, Gitea repo link, and live dev ticker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - © Jason Stedwell copyright with current year - Gitea icon + link to https://git.alwisp.com/jason/cpas - Running elapsed time ticker since first commit (2026-03-06T11:33:32) ticks every second: Xd HHh MMm SSs format - App layout changed to flex column so footer pins to page bottom - Footer styles isolated in `sf` object for clarity --- client/src/App.jsx | 77 +--------------------------------------------- 1 file changed, 1 insertion(+), 76 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index b3645c2..df8dadf 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,76 +1 @@ -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' }, - { id: 'violation', label: '+ New Violation' }, -]; - -const s = { - app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa' }, - nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, - logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' }, - logoImg: { height: '28px', marginRight: '10px' }, - logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' }, - tab: (active) => ({ - padding: '18px 22px', - color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)', - borderBottom: active ? '3px solid #d4af37' : '3px solid transparent', - cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', - background: 'none', border: 'none', - }), - // Docs button sits flush-right in the nav - docsBtn: { - marginLeft: 'auto', - background: 'none', - border: '1px solid #2a2b3a', - color: '#9ca0b8', - borderRadius: '6px', - padding: '6px 14px', - fontSize: '12px', - cursor: 'pointer', - fontWeight: 600, - letterSpacing: '0.3px', - display: 'flex', - alignItems: 'center', - gap: '6px', - }, - card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' }, -}; - -export default function App() { - const [tab, setTab] = useState('dashboard'); - const [showReadme, setShowReadme] = useState(false); - - return ( - -
- - -
- {tab === 'dashboard' ? : } -
- - {showReadme && setShowReadme(false)} />} -
-
- ); -} +aW1wb3J0IFJlYWN0LCB7IHVzZVN0YXRlLCB1c2VFZmZlY3QgfSBmcm9tICdyZWFjdCc7CmltcG9ydCBWaW9sYXRpb25Gb3JtIGZyb20gJy4vY29tcG9uZW50cy9WaW9sYXRpb25Gb3JtJzsKaW1wb3J0IERhc2hib2FyZCAgICAgZnJvbSAnLi9jb21wb25lbnRzL0Rhc2hib2FyZCc7CmltcG9ydCBSZWFkbWVNb2RhbCAgIGZyb20gJy4vY29tcG9uZW50cy9SZWFkbWVNb2RhbCc7CmltcG9ydCBUb2FzdFByb3ZpZGVyIGZyb20gJy4vY29tcG9uZW50cy9Ub2FzdFByb3ZpZGVyJzsKCmNvbnN0IFJFUE9fVVJMICAgICAgPSAnaHR0cHM6Ly9naXQuYWx3aXNwLmNvbS9qYXNvbi9jcGFzJzsKLy8gRmlyc3QgY29tbWl0IHRpbWVzdGFtcCDigJQgdXNlZCB0byBkcml2ZSB0aGUgZGV2IHRpY2tlcgpjb25zdCBQUk9KRUNUX1NUQVJUID0gbmV3IERhdGUoJzIwMjYtMDMtMDZUMTE6MzM6MzItMDY6MDAnKTsKCmNvbnN0IHRhYnMgPSBbCiAgeyBpZDogJ2Rhc2hib2FyZCcsIGxhYmVsOiAn8J+TiiBEYXNoYm9hcmQnIH0sCiAgeyBpZDogJ3Zpb2xhdGlvbicsIGxhYmVsOiAnKyBOZXcgVmlvbGF0aW9uJyB9LApdOwoKLyog4pSA4pSAIGhlbHBlcnMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSAICovCmZ1bmN0aW9uIGVsYXBzZWQoZnJvbSkgewogIGNvbnN0IGRpZmYgPSBNYXRoLmZsb29yKChEYXRlLm5vdygpIC0gZnJvbS5nZXRUaW1lKCkpIC8gMTAwMCk7CiAgY29uc3QgZCA9IE1hdGguZmxvb3IoZGlmZiAvIDg2NDAwKTsKICBjb25zdCBoID0gTWF0aC5mbG9vcigoZGlmZiAlIDg2NDAwKSAvIDM2MDApOwogIGNvbnN0IG0gPSBNYXRoLmZsb29yKChkaWZmICUgMzYwMCkgLyA2MCk7CiAgY29uc3QgcyA9IGRpZmYgJSA2MDsKICByZXR1cm4gYCR7ZH1kICR7U3RyaW5nKGgpLnBhZFN0YXJ0KDIsJzAnKX1oICR7U3RyaW5nKG0pLnBhZFN0YXJ0KDIsJzAnKX1tICR7U3RyaW5nKHMpLnBhZFN0YXJ0KDIsJzAnKX1zYDsKfQoKLyog4pSA4pSAIHN1Yi1jb21wb25lbnRzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgCAqLwpmdW5jdGlvbiBEZXZUaWNrZXIoKSB7CiAgY29uc3QgW3RpY2ssIHNldFRpY2tdID0gdXNlU3RhdGUoKCkgPT4gZWxhcHNlZChQUk9KRUNUX1NUQVJUKSk7CiAgdXNlRWZmZWN0KCgpID0+IHsKICAgIGNvbnN0IGlkID0gc2V0SW50ZXJ2YWwoKCkgPT4gc2V0VGljayhlbGFwc2VkKFBST0pFQ1RfU1RBUlQpKSwgMTAwMCk7CiAgICByZXR1cm4gKCkgPT4gY2xlYXJJbnRlcnZhbChpZCk7CiAgfSwgW10pOwogIHJldHVybiAoCiAgICA8c3BhbiBzdHlsZT17c2YudGlja2VyfSB0aXRsZT0iVGltZSBzaW5jZSBmaXJzdCBjb21taXQiPgogICAgICA8c3BhbiBzdHlsZT17c2YudGlja2VyRG90fSAvPgogICAgICB7dGlja30KICAgIDwvc3Bhbj4KICApOwp9CgpmdW5jdGlvbiBHaXRlYUljb24oKSB7CiAgLy8gU2ltcGxlIEdpdGVhLXN0eWxlIGljb24gdXNpbmcgU1ZHIHBhdGgKICByZXR1cm4gKAogICAgPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBzdHlsZT17eyBkaXNwbGF5OidibG9jaycgfX0+CiAgICAgIDxwYXRoIGQ9Ik0xMiAyQzYuNDc3IDIgMiA2LjQ3NyAyIDEyYzAgNC40MTggMi44NjUgOC4xNjYgNi44MzkgOS40ODkuNS4wOTIuNjgyLS4yMTcuNjgyLS40ODIKICAgICAgICAgICAgICAgMC0uMjM3LS4wMDktLjg2OC0uMDEzLTEuNzAzLTIuNzgyLjYwNS0zLjM2OS0xLjM0LTMuMzY5LTEuMzQtLjQ1NC0xLjE1NC0xLjExLTEuNDYyCiAgICAgICAgICAgICAgIC0xLjExLTEuNDYyLS45MDgtLjYyLjA2OS0uNjA4LjA2OS0uNjA4IDEuMDAzLjA3IDEuNTMxIDEuMDMgMS41MzEgMS4wMy44OTIgMS41MjkKICAgICAgICAgICAgICAgMi4zNDEgMS4wODcgMi45MS44MzIuMDkyLS42NDcuMzUtMS4wODguNjM2LTEuMzM4LTIuMjItLjI1My00LjU1NS0xLjExLTQuNTU1LTQuOTQzCiAgICAgICAgICAgICAgIDAtMS4wOTEuMzktMS45ODQgMS4wMjktMi42ODMtLjEwMy0uMjUzLS40NDYtMS4yNy4wOTgtMi42NDcgMCAwIC44NC0uMjY5IDIuNzUgMS4wMjUKICAgICAgICAgICAgICAgQTkuNTY0IDkuNTY0IDAgMDExMiA2Ljg0NGE5LjU5IDkuNTkgMCAwMTIuNTA0LjMzN2MxLjkwOS0xLjI5NCAyLjc0Ny0xLjAyNSAyLjc0Ny0xLjAyNQogICAgICAgICAgICAgICAuNTQ2IDEuMzc3LjIwMiAyLjM5NC4xIDIuNjQ3LjY0LjY5OSAxLjAyOCAxLjU5MiAxLjAyOCAyLjY4MyAwIDMuODQyLTIuMzM5IDQuNjg3CiAgICAgICAgICAgICAgIC00LjU2NiA0LjkzNS4zNTkuMzA5LjY3OC45MTkuNjc4IDEuODUyIDAgMS4zMzYtLjAxMiAyLjQxNS0uMDEyIDIuNzQzIDAgLjI2Ny4xOC41NzgKICAgICAgICAgICAgICAgLjY4OC40OEMxOS4xMzggMjAuMTYzIDIyIDE2LjQxOCAyMiAxMmMwLTUuNTIzLTQuNDc3LTEwLTEwLTEweiIvPgogICAgPC9zdmc+CiAgKTsKfQoKZnVuY3Rpb24gQXBwRm9vdGVyKCkgewogIHJldHVybiAoCiAgICA8Zm9vdGVyIHN0eWxlPXtzZi5mb290ZXJ9PgogICAgICA8c3BhbiBzdHlsZT17c2YuY29weX0+wqkge25ldyBEYXRlKCkuZ2V0RnVsbFllYXIoKX0gSmFzb24gU3RlZHdlbGw8L3NwYW4+CiAgICAgIDxzcGFuIHN0eWxlPXtzZi5zZXB9PsK3PC9zcGFuPgogICAgICA8RGV2VGlja2VyIC8+CiAgICAgIDxzcGFuIHN0eWxlPXtzZi5zZXB9PsK3PC9zcGFuPgogICAgICA8YSBocmVmPXtSRVBPX1VSTH0gdGFyZ2V0PSJfYmxhbmsiIHJlbD0ibm9vcGVuZXIgbm9yZWZlcnJlciIgc3R5bGU9e3NmLnJlcG9MaW5rfSB0aXRsZT0iVmlldyBzb3VyY2Ugb24gR2l0ZWEiPgogICAgICAgIDxHaXRlYUljb24gLz4KICAgICAgICA8c3Bhbj5jcGFzPC9zcGFuPgogICAgICA8L2E+CiAgICA8L2Zvb3Rlcj4KICApOwp9CgovKiDilIDilIAgc3R5bGVzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgCAqLwpjb25zdCBzID0gewogIGFwcDogICAgICB7IG1pbkhlaWdodDogJzEwMHZoJywgYmFja2dyb3VuZDogJyMwNTA2MDgnLCBmb250RmFtaWx5OiAiJ1NlZ29lIFVJJywgQXJpYWwsIHNhbnMtc2VyaWYiLCBjb2xvcjogJyNmOGY5ZmEnLCBkaXNwbGF5OiAnZmxleCcsIGZsZXhEaXJlY3Rpb246ICdjb2x1bW4nIH0sCiAgbmF2OiAgICAgIHsgYmFja2dyb3VuZDogJyMwMDAwMDAnLCBwYWRkaW5nOiAnMCA0MHB4JywgZGlzcGxheTogJ2ZsZXgnLCBhbGlnbkl0ZW1zOiAnY2VudGVyJywgZ2FwOiAwLCBib3JkZXJCb3R0b206ICcxcHggc29saWQgIzMzMycgfSwKICBsb2dvV3JhcDogeyBkaXNwbGF5OiAnZmxleCcsIGFsaWduSXRlbXM6ICdjZW50ZXInLCBtYXJnaW5SaWdodDogJzMycHgnLCBwYWRkaW5nOiAnMTRweCAwJyB9LAogIGxvZ29JbWc6ICB7IGhlaWdodDogJzI4cHgnLCBtYXJnaW5SaWdodDogJzEwcHgnIH0sCiAgbG9nb1RleHQ6IHsgY29sb3I6ICcjZjhmOWZhJywgZm9udFdlaWdodDogODAwLCBmb250U2l6ZTogJzE4cHgnLCBsZXR0ZXJTcGFjaW5nOiAnMC41cHgnIH0sCiAgdGFiOiAoYWN0aXZlKSA9PiAoewogICAgcGFkZGluZzogJzE4cHggMjJweCcsCiAgICBjb2xvcjogYWN0aXZlID8gJyNmOGY5ZmEnIDogJ3JnYmEoMjQ4LDI0OSwyNTAsMC42KScsCiAgICBib3JkZXJCb3R0b206IGFjdGl2ZSA/ICczcHggc29saWQgI2Q0YWYzNycgOiAnM3B4IHNvbGlkIHRyYW5zcGFyZW50JywKICAgIGN1cnNvcjogJ3BvaW50ZXInLCBmb250V2VpZ2h0OiBhY3RpdmUgPyA3MDAgOiA0MDAsIGZvbnRTaXplOiAnMTRweCcsCiAgICBiYWNrZ3JvdW5kOiAnbm9uZScsIGJvcmRlcjogJ25vbmUnLAogIH0pLAogIGRvY3NCdG46IHsKICAgIG1hcmdpbkxlZnQ6ICdhdXRvJywKICAgIGJhY2tncm91bmQ6ICdub25lJywKICAgIGJvcmRlcjogJzFweCBzb2xpZCAjMmEyYjNhJywKICAgIGNvbG9yOiAnIzljYTBiOCcsCiAgICBib3JkZXJSYWRpdXM6ICc2cHgnLAogICAgcGFkZGluZzogJzZweCAxNHB4JywKICAgIGZvbnRTaXplOiAnMTJweCcsCiAgICBjdXJzb3I6ICdwb2ludGVyJywKICAgIGZvbnRXZWlnaHQ6IDYwMCwKICAgIGxldHRlclNwYWNpbmc6ICcwLjNweCcsCiAgICBkaXNwbGF5OiAnZmxleCcsCiAgICBhbGlnbkl0ZW1zOiAnY2VudGVyJywKICAgIGdhcDogJzZweCcsCiAgfSwKICBtYWluOiB7IGZsZXg6IDEgfSwKICBjYXJkOiB7IG1heFdpZHRoOiAnMTEwMHB4JywgbWFyZ2luOiAnMzBweCBhdXRvJywgYmFja2dyb3VuZDogJyMxMTEyMTcnLCBib3JkZXJSYWRpdXM6ICcxMHB4JywgYm94U2hhZG93OiAnMCAycHggMTZweCByZ2JhKDAsMCwwLDAuNiknLCBib3JkZXI6ICcxcHggc29saWQgIzIyMicgfSwKfTsKCi8vIEZvb3Rlci1zcGVjaWZpYyBzdHlsZXMga2VwdCBzZXBhcmF0ZSBmb3IgY2xhcml0eQpjb25zdCBzZiA9IHsKICBmb290ZXI6IHsKICAgIGJhY2tncm91bmQ6ICcjMDAwJywKICAgIGJvcmRlclRvcDogJzFweCBzb2xpZCAjMWUxZjJlJywKICAgIHBhZGRpbmc6ICcxMnB4IDQwcHgnLAogICAgZGlzcGxheTogJ2ZsZXgnLAogICAgYWxpZ25JdGVtczogJ2NlbnRlcicsCiAgICBnYXA6ICcxMHB4JywKICAgIGZvbnRTaXplOiAnMTJweCcsCiAgICBjb2xvcjogJyM0YTRkNjYnLAogICAgZmxleFdyYXA6ICd3cmFwJywKICB9LAogIGNvcHk6ICAgICAgeyBjb2xvcjogJyM0YTRkNjYnLCB3aGl0ZVNwYWNlOiAnbm93cmFwJyB9LAogIHNlcDogICAgICAgeyBjb2xvcjogJyMyYTJiM2EnLCB1c2VyU2VsZWN0OiAnbm9uZScgfSwKICB0aWNrZXI6IHsKICAgIGRpc3BsYXk6ICdpbmxpbmUtZmxleCcsCiAgICBhbGlnbkl0ZW1zOiAnY2VudGVyJywKICAgIGdhcDogJzZweCcsCiAgICBmb250RmFtaWx5OiAiJ0NvdXJpZXIgTmV3JywgbW9ub3NwYWNlIiwKICAgIGZvbnRTaXplOiAnMTFweCcsCiAgICBjb2xvcjogJyM1YTdhNWEnLAogICAgbGV0dGVyU3BhY2luZzogJzAuNXB4JywKICB9LAogIHRpY2tlckRvdDogewogICAgd2lkdGg6ICc2cHgnLAogICAgaGVpZ2h0OiAnNnB4JywKICAgIGJvcmRlclJhZGl1czogJzUwJScsCiAgICBiYWNrZ3JvdW5kOiAnIzNhNmEzYScsCiAgICBib3hTaGFkb3c6ICcwIDAgNHB4ICMzYTZhM2EnLAogICAgZmxleFNocmluazogMCwKICB9LAogIHJlcG9MaW5rOiB7CiAgICBkaXNwbGF5OiAnaW5saW5lLWZsZXgnLAogICAgYWxpZ25JdGVtczogJ2NlbnRlcicsCiAgICBnYXA6ICc1cHgnLAogICAgY29sb3I6ICcjNGE0ZDY2JywKICAgIHRleHREZWNvcmF0aW9uOiAnbm9uZScsCiAgICB0cmFuc2l0aW9uOiAnY29sb3IgMC4xNXMnLAogIH0sCn07CgovKiDilIDilIAgbWFpbiBhcHAg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSAICovCmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIEFwcCgpIHsKICBjb25zdCBbdGFiLCAgICAgICAgc2V0VGFiXSAgICAgICAgPSB1c2VTdGF0ZSgnZGFzaGJvYXJkJyk7CiAgY29uc3QgW3Nob3dSZWFkbWUsIHNldFNob3dSZWFkbWVdID0gdXNlU3RhdGUoZmFsc2UpOwoKICByZXR1cm4gKAogICAgPFRvYXN0UHJvdmlkZXI+CiAgICAgIDxkaXYgc3R5bGU9e3MuYXBwfT4KICAgICAgICA8bmF2IHN0eWxlPXtzLm5hdn0+CiAgICAgICAgICA8ZGl2IHN0eWxlPXtzLmxvZ29XcmFwfT4KICAgICAgICAgICAgPGltZyBzcmM9Ii9zdGF0aWMvbXBtLWxvZ28ucG5nIiBhbHQ9Ik1QTSIgc3R5bGU9e3MubG9nb0ltZ30gLz4KICAgICAgICAgICAgPGRpdiBzdHlsZT17cy5sb2dvVGV4dH0+Q1BBUyBUcmFja2VyPC9kaXY+CiAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICB7dGFicy5tYXAodCA9PiAoCiAgICAgICAgICAgIDxidXR0b24ga2V5PXt0LmlkfSBzdHlsZT17cy50YWIodGFiID09PSB0LmlkKX0gb25DbGljaz17KCkgPT4gc2V0VGFiKHQuaWQpfT4KICAgICAgICAgICAgICB7dC5sYWJlbH0KICAgICAgICAgICAgPC9idXR0b24+CiAgICAgICAgICApKX0KCiAgICAgICAgICA8YnV0dG9uIHN0eWxlPXtzLmRvY3NCdG59IG9uQ2xpY2s9eygpID0+IHNldFNob3dSZWFkbWUodHJ1ZSl9IHRpdGxlPSJPcGVuIGFkbWluIGRvY3VtZW50YXRpb24iPgogICAgICAgICAgICA8c3Bhbj4/PC9zcGFuPiBEb2NzCiAgICAgICAgICA8L2J1dHRvbj4KICAgICAgICA8L25hdj4KCiAgICAgICAgPGRpdiBzdHlsZT17cy5tYWlufT4KICAgICAgICAgIDxkaXYgc3R5bGU9e3MuY2FyZH0+CiAgICAgICAgICAgIHt0YWIgPT09ICdkYXNoYm9hcmQnID8gPERhc2hib2FyZCAvPiA6IDxWaW9sYXRpb25Gb3JtIC8+fQogICAgICAgICAgPC9kaXY+CiAgICAgICAgPC9kaXY+CgogICAgICAgIDxBcHBGb290ZXIgLz4KCiAgICAgICAge3Nob3dSZWFkbWUgJiYgPFJlYWRtZU1vZGFsIG9uQ2xvc2U9eygpID0+IHNldFNob3dSZWFkbWUoZmFsc2UpfSAvPn0KICAgICAgPC9kaXY+CiAgICA8L1RvYXN0UHJvdmlkZXI+CiAgKTsKfQo= \ No newline at end of file -- 2.52.0 From 5f0ae959ede4dca006298447ddc3dd42514bacc1 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:25:11 -0600 Subject: [PATCH 24/54] Update client/src/App.jsx --- client/src/App.jsx | 77 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index df8dadf..b3645c2 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1 +1,76 @@ -aW1wb3J0IFJlYWN0LCB7IHVzZVN0YXRlLCB1c2VFZmZlY3QgfSBmcm9tICdyZWFjdCc7CmltcG9ydCBWaW9sYXRpb25Gb3JtIGZyb20gJy4vY29tcG9uZW50cy9WaW9sYXRpb25Gb3JtJzsKaW1wb3J0IERhc2hib2FyZCAgICAgZnJvbSAnLi9jb21wb25lbnRzL0Rhc2hib2FyZCc7CmltcG9ydCBSZWFkbWVNb2RhbCAgIGZyb20gJy4vY29tcG9uZW50cy9SZWFkbWVNb2RhbCc7CmltcG9ydCBUb2FzdFByb3ZpZGVyIGZyb20gJy4vY29tcG9uZW50cy9Ub2FzdFByb3ZpZGVyJzsKCmNvbnN0IFJFUE9fVVJMICAgICAgPSAnaHR0cHM6Ly9naXQuYWx3aXNwLmNvbS9qYXNvbi9jcGFzJzsKLy8gRmlyc3QgY29tbWl0IHRpbWVzdGFtcCDigJQgdXNlZCB0byBkcml2ZSB0aGUgZGV2IHRpY2tlcgpjb25zdCBQUk9KRUNUX1NUQVJUID0gbmV3IERhdGUoJzIwMjYtMDMtMDZUMTE6MzM6MzItMDY6MDAnKTsKCmNvbnN0IHRhYnMgPSBbCiAgeyBpZDogJ2Rhc2hib2FyZCcsIGxhYmVsOiAn8J+TiiBEYXNoYm9hcmQnIH0sCiAgeyBpZDogJ3Zpb2xhdGlvbicsIGxhYmVsOiAnKyBOZXcgVmlvbGF0aW9uJyB9LApdOwoKLyog4pSA4pSAIGhlbHBlcnMg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSAICovCmZ1bmN0aW9uIGVsYXBzZWQoZnJvbSkgewogIGNvbnN0IGRpZmYgPSBNYXRoLmZsb29yKChEYXRlLm5vdygpIC0gZnJvbS5nZXRUaW1lKCkpIC8gMTAwMCk7CiAgY29uc3QgZCA9IE1hdGguZmxvb3IoZGlmZiAvIDg2NDAwKTsKICBjb25zdCBoID0gTWF0aC5mbG9vcigoZGlmZiAlIDg2NDAwKSAvIDM2MDApOwogIGNvbnN0IG0gPSBNYXRoLmZsb29yKChkaWZmICUgMzYwMCkgLyA2MCk7CiAgY29uc3QgcyA9IGRpZmYgJSA2MDsKICByZXR1cm4gYCR7ZH1kICR7U3RyaW5nKGgpLnBhZFN0YXJ0KDIsJzAnKX1oICR7U3RyaW5nKG0pLnBhZFN0YXJ0KDIsJzAnKX1tICR7U3RyaW5nKHMpLnBhZFN0YXJ0KDIsJzAnKX1zYDsKfQoKLyog4pSA4pSAIHN1Yi1jb21wb25lbnRzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgCAqLwpmdW5jdGlvbiBEZXZUaWNrZXIoKSB7CiAgY29uc3QgW3RpY2ssIHNldFRpY2tdID0gdXNlU3RhdGUoKCkgPT4gZWxhcHNlZChQUk9KRUNUX1NUQVJUKSk7CiAgdXNlRWZmZWN0KCgpID0+IHsKICAgIGNvbnN0IGlkID0gc2V0SW50ZXJ2YWwoKCkgPT4gc2V0VGljayhlbGFwc2VkKFBST0pFQ1RfU1RBUlQpKSwgMTAwMCk7CiAgICByZXR1cm4gKCkgPT4gY2xlYXJJbnRlcnZhbChpZCk7CiAgfSwgW10pOwogIHJldHVybiAoCiAgICA8c3BhbiBzdHlsZT17c2YudGlja2VyfSB0aXRsZT0iVGltZSBzaW5jZSBmaXJzdCBjb21taXQiPgogICAgICA8c3BhbiBzdHlsZT17c2YudGlja2VyRG90fSAvPgogICAgICB7dGlja30KICAgIDwvc3Bhbj4KICApOwp9CgpmdW5jdGlvbiBHaXRlYUljb24oKSB7CiAgLy8gU2ltcGxlIEdpdGVhLXN0eWxlIGljb24gdXNpbmcgU1ZHIHBhdGgKICByZXR1cm4gKAogICAgPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBzdHlsZT17eyBkaXNwbGF5OidibG9jaycgfX0+CiAgICAgIDxwYXRoIGQ9Ik0xMiAyQzYuNDc3IDIgMiA2LjQ3NyAyIDEyYzAgNC40MTggMi44NjUgOC4xNjYgNi44MzkgOS40ODkuNS4wOTIuNjgyLS4yMTcuNjgyLS40ODIKICAgICAgICAgICAgICAgMC0uMjM3LS4wMDktLjg2OC0uMDEzLTEuNzAzLTIuNzgyLjYwNS0zLjM2OS0xLjM0LTMuMzY5LTEuMzQtLjQ1NC0xLjE1NC0xLjExLTEuNDYyCiAgICAgICAgICAgICAgIC0xLjExLTEuNDYyLS45MDgtLjYyLjA2OS0uNjA4LjA2OS0uNjA4IDEuMDAzLjA3IDEuNTMxIDEuMDMgMS41MzEgMS4wMy44OTIgMS41MjkKICAgICAgICAgICAgICAgMi4zNDEgMS4wODcgMi45MS44MzIuMDkyLS42NDcuMzUtMS4wODguNjM2LTEuMzM4LTIuMjItLjI1My00LjU1NS0xLjExLTQuNTU1LTQuOTQzCiAgICAgICAgICAgICAgIDAtMS4wOTEuMzktMS45ODQgMS4wMjktMi42ODMtLjEwMy0uMjUzLS40NDYtMS4yNy4wOTgtMi42NDcgMCAwIC44NC0uMjY5IDIuNzUgMS4wMjUKICAgICAgICAgICAgICAgQTkuNTY0IDkuNTY0IDAgMDExMiA2Ljg0NGE5LjU5IDkuNTkgMCAwMTIuNTA0LjMzN2MxLjkwOS0xLjI5NCAyLjc0Ny0xLjAyNSAyLjc0Ny0xLjAyNQogICAgICAgICAgICAgICAuNTQ2IDEuMzc3LjIwMiAyLjM5NC4xIDIuNjQ3LjY0LjY5OSAxLjAyOCAxLjU5MiAxLjAyOCAyLjY4MyAwIDMuODQyLTIuMzM5IDQuNjg3CiAgICAgICAgICAgICAgIC00LjU2NiA0LjkzNS4zNTkuMzA5LjY3OC45MTkuNjc4IDEuODUyIDAgMS4zMzYtLjAxMiAyLjQxNS0uMDEyIDIuNzQzIDAgLjI2Ny4xOC41NzgKICAgICAgICAgICAgICAgLjY4OC40OEMxOS4xMzggMjAuMTYzIDIyIDE2LjQxOCAyMiAxMmMwLTUuNTIzLTQuNDc3LTEwLTEwLTEweiIvPgogICAgPC9zdmc+CiAgKTsKfQoKZnVuY3Rpb24gQXBwRm9vdGVyKCkgewogIHJldHVybiAoCiAgICA8Zm9vdGVyIHN0eWxlPXtzZi5mb290ZXJ9PgogICAgICA8c3BhbiBzdHlsZT17c2YuY29weX0+wqkge25ldyBEYXRlKCkuZ2V0RnVsbFllYXIoKX0gSmFzb24gU3RlZHdlbGw8L3NwYW4+CiAgICAgIDxzcGFuIHN0eWxlPXtzZi5zZXB9PsK3PC9zcGFuPgogICAgICA8RGV2VGlja2VyIC8+CiAgICAgIDxzcGFuIHN0eWxlPXtzZi5zZXB9PsK3PC9zcGFuPgogICAgICA8YSBocmVmPXtSRVBPX1VSTH0gdGFyZ2V0PSJfYmxhbmsiIHJlbD0ibm9vcGVuZXIgbm9yZWZlcnJlciIgc3R5bGU9e3NmLnJlcG9MaW5rfSB0aXRsZT0iVmlldyBzb3VyY2Ugb24gR2l0ZWEiPgogICAgICAgIDxHaXRlYUljb24gLz4KICAgICAgICA8c3Bhbj5jcGFzPC9zcGFuPgogICAgICA8L2E+CiAgICA8L2Zvb3Rlcj4KICApOwp9CgovKiDilIDilIAgc3R5bGVzIOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgCAqLwpjb25zdCBzID0gewogIGFwcDogICAgICB7IG1pbkhlaWdodDogJzEwMHZoJywgYmFja2dyb3VuZDogJyMwNTA2MDgnLCBmb250RmFtaWx5OiAiJ1NlZ29lIFVJJywgQXJpYWwsIHNhbnMtc2VyaWYiLCBjb2xvcjogJyNmOGY5ZmEnLCBkaXNwbGF5OiAnZmxleCcsIGZsZXhEaXJlY3Rpb246ICdjb2x1bW4nIH0sCiAgbmF2OiAgICAgIHsgYmFja2dyb3VuZDogJyMwMDAwMDAnLCBwYWRkaW5nOiAnMCA0MHB4JywgZGlzcGxheTogJ2ZsZXgnLCBhbGlnbkl0ZW1zOiAnY2VudGVyJywgZ2FwOiAwLCBib3JkZXJCb3R0b206ICcxcHggc29saWQgIzMzMycgfSwKICBsb2dvV3JhcDogeyBkaXNwbGF5OiAnZmxleCcsIGFsaWduSXRlbXM6ICdjZW50ZXInLCBtYXJnaW5SaWdodDogJzMycHgnLCBwYWRkaW5nOiAnMTRweCAwJyB9LAogIGxvZ29JbWc6ICB7IGhlaWdodDogJzI4cHgnLCBtYXJnaW5SaWdodDogJzEwcHgnIH0sCiAgbG9nb1RleHQ6IHsgY29sb3I6ICcjZjhmOWZhJywgZm9udFdlaWdodDogODAwLCBmb250U2l6ZTogJzE4cHgnLCBsZXR0ZXJTcGFjaW5nOiAnMC41cHgnIH0sCiAgdGFiOiAoYWN0aXZlKSA9PiAoewogICAgcGFkZGluZzogJzE4cHggMjJweCcsCiAgICBjb2xvcjogYWN0aXZlID8gJyNmOGY5ZmEnIDogJ3JnYmEoMjQ4LDI0OSwyNTAsMC42KScsCiAgICBib3JkZXJCb3R0b206IGFjdGl2ZSA/ICczcHggc29saWQgI2Q0YWYzNycgOiAnM3B4IHNvbGlkIHRyYW5zcGFyZW50JywKICAgIGN1cnNvcjogJ3BvaW50ZXInLCBmb250V2VpZ2h0OiBhY3RpdmUgPyA3MDAgOiA0MDAsIGZvbnRTaXplOiAnMTRweCcsCiAgICBiYWNrZ3JvdW5kOiAnbm9uZScsIGJvcmRlcjogJ25vbmUnLAogIH0pLAogIGRvY3NCdG46IHsKICAgIG1hcmdpbkxlZnQ6ICdhdXRvJywKICAgIGJhY2tncm91bmQ6ICdub25lJywKICAgIGJvcmRlcjogJzFweCBzb2xpZCAjMmEyYjNhJywKICAgIGNvbG9yOiAnIzljYTBiOCcsCiAgICBib3JkZXJSYWRpdXM6ICc2cHgnLAogICAgcGFkZGluZzogJzZweCAxNHB4JywKICAgIGZvbnRTaXplOiAnMTJweCcsCiAgICBjdXJzb3I6ICdwb2ludGVyJywKICAgIGZvbnRXZWlnaHQ6IDYwMCwKICAgIGxldHRlclNwYWNpbmc6ICcwLjNweCcsCiAgICBkaXNwbGF5OiAnZmxleCcsCiAgICBhbGlnbkl0ZW1zOiAnY2VudGVyJywKICAgIGdhcDogJzZweCcsCiAgfSwKICBtYWluOiB7IGZsZXg6IDEgfSwKICBjYXJkOiB7IG1heFdpZHRoOiAnMTEwMHB4JywgbWFyZ2luOiAnMzBweCBhdXRvJywgYmFja2dyb3VuZDogJyMxMTEyMTcnLCBib3JkZXJSYWRpdXM6ICcxMHB4JywgYm94U2hhZG93OiAnMCAycHggMTZweCByZ2JhKDAsMCwwLDAuNiknLCBib3JkZXI6ICcxcHggc29saWQgIzIyMicgfSwKfTsKCi8vIEZvb3Rlci1zcGVjaWZpYyBzdHlsZXMga2VwdCBzZXBhcmF0ZSBmb3IgY2xhcml0eQpjb25zdCBzZiA9IHsKICBmb290ZXI6IHsKICAgIGJhY2tncm91bmQ6ICcjMDAwJywKICAgIGJvcmRlclRvcDogJzFweCBzb2xpZCAjMWUxZjJlJywKICAgIHBhZGRpbmc6ICcxMnB4IDQwcHgnLAogICAgZGlzcGxheTogJ2ZsZXgnLAogICAgYWxpZ25JdGVtczogJ2NlbnRlcicsCiAgICBnYXA6ICcxMHB4JywKICAgIGZvbnRTaXplOiAnMTJweCcsCiAgICBjb2xvcjogJyM0YTRkNjYnLAogICAgZmxleFdyYXA6ICd3cmFwJywKICB9LAogIGNvcHk6ICAgICAgeyBjb2xvcjogJyM0YTRkNjYnLCB3aGl0ZVNwYWNlOiAnbm93cmFwJyB9LAogIHNlcDogICAgICAgeyBjb2xvcjogJyMyYTJiM2EnLCB1c2VyU2VsZWN0OiAnbm9uZScgfSwKICB0aWNrZXI6IHsKICAgIGRpc3BsYXk6ICdpbmxpbmUtZmxleCcsCiAgICBhbGlnbkl0ZW1zOiAnY2VudGVyJywKICAgIGdhcDogJzZweCcsCiAgICBmb250RmFtaWx5OiAiJ0NvdXJpZXIgTmV3JywgbW9ub3NwYWNlIiwKICAgIGZvbnRTaXplOiAnMTFweCcsCiAgICBjb2xvcjogJyM1YTdhNWEnLAogICAgbGV0dGVyU3BhY2luZzogJzAuNXB4JywKICB9LAogIHRpY2tlckRvdDogewogICAgd2lkdGg6ICc2cHgnLAogICAgaGVpZ2h0OiAnNnB4JywKICAgIGJvcmRlclJhZGl1czogJzUwJScsCiAgICBiYWNrZ3JvdW5kOiAnIzNhNmEzYScsCiAgICBib3hTaGFkb3c6ICcwIDAgNHB4ICMzYTZhM2EnLAogICAgZmxleFNocmluazogMCwKICB9LAogIHJlcG9MaW5rOiB7CiAgICBkaXNwbGF5OiAnaW5saW5lLWZsZXgnLAogICAgYWxpZ25JdGVtczogJ2NlbnRlcicsCiAgICBnYXA6ICc1cHgnLAogICAgY29sb3I6ICcjNGE0ZDY2JywKICAgIHRleHREZWNvcmF0aW9uOiAnbm9uZScsCiAgICB0cmFuc2l0aW9uOiAnY29sb3IgMC4xNXMnLAogIH0sCn07CgovKiDilIDilIAgbWFpbiBhcHAg4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSAICovCmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIEFwcCgpIHsKICBjb25zdCBbdGFiLCAgICAgICAgc2V0VGFiXSAgICAgICAgPSB1c2VTdGF0ZSgnZGFzaGJvYXJkJyk7CiAgY29uc3QgW3Nob3dSZWFkbWUsIHNldFNob3dSZWFkbWVdID0gdXNlU3RhdGUoZmFsc2UpOwoKICByZXR1cm4gKAogICAgPFRvYXN0UHJvdmlkZXI+CiAgICAgIDxkaXYgc3R5bGU9e3MuYXBwfT4KICAgICAgICA8bmF2IHN0eWxlPXtzLm5hdn0+CiAgICAgICAgICA8ZGl2IHN0eWxlPXtzLmxvZ29XcmFwfT4KICAgICAgICAgICAgPGltZyBzcmM9Ii9zdGF0aWMvbXBtLWxvZ28ucG5nIiBhbHQ9Ik1QTSIgc3R5bGU9e3MubG9nb0ltZ30gLz4KICAgICAgICAgICAgPGRpdiBzdHlsZT17cy5sb2dvVGV4dH0+Q1BBUyBUcmFja2VyPC9kaXY+CiAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICB7dGFicy5tYXAodCA9PiAoCiAgICAgICAgICAgIDxidXR0b24ga2V5PXt0LmlkfSBzdHlsZT17cy50YWIodGFiID09PSB0LmlkKX0gb25DbGljaz17KCkgPT4gc2V0VGFiKHQuaWQpfT4KICAgICAgICAgICAgICB7dC5sYWJlbH0KICAgICAgICAgICAgPC9idXR0b24+CiAgICAgICAgICApKX0KCiAgICAgICAgICA8YnV0dG9uIHN0eWxlPXtzLmRvY3NCdG59IG9uQ2xpY2s9eygpID0+IHNldFNob3dSZWFkbWUodHJ1ZSl9IHRpdGxlPSJPcGVuIGFkbWluIGRvY3VtZW50YXRpb24iPgogICAgICAgICAgICA8c3Bhbj4/PC9zcGFuPiBEb2NzCiAgICAgICAgICA8L2J1dHRvbj4KICAgICAgICA8L25hdj4KCiAgICAgICAgPGRpdiBzdHlsZT17cy5tYWlufT4KICAgICAgICAgIDxkaXYgc3R5bGU9e3MuY2FyZH0+CiAgICAgICAgICAgIHt0YWIgPT09ICdkYXNoYm9hcmQnID8gPERhc2hib2FyZCAvPiA6IDxWaW9sYXRpb25Gb3JtIC8+fQogICAgICAgICAgPC9kaXY+CiAgICAgICAgPC9kaXY+CgogICAgICAgIDxBcHBGb290ZXIgLz4KCiAgICAgICAge3Nob3dSZWFkbWUgJiYgPFJlYWRtZU1vZGFsIG9uQ2xvc2U9eygpID0+IHNldFNob3dSZWFkbWUoZmFsc2UpfSAvPn0KICAgICAgPC9kaXY+CiAgICA8L1RvYXN0UHJvdmlkZXI+CiAgKTsKfQo= \ No newline at end of file +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' }, + { id: 'violation', label: '+ New Violation' }, +]; + +const s = { + app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa' }, + nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, + logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' }, + logoImg: { height: '28px', marginRight: '10px' }, + logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' }, + tab: (active) => ({ + padding: '18px 22px', + color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)', + borderBottom: active ? '3px solid #d4af37' : '3px solid transparent', + cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', + background: 'none', border: 'none', + }), + // Docs button sits flush-right in the nav + docsBtn: { + marginLeft: 'auto', + background: 'none', + border: '1px solid #2a2b3a', + color: '#9ca0b8', + borderRadius: '6px', + padding: '6px 14px', + fontSize: '12px', + cursor: 'pointer', + fontWeight: 600, + letterSpacing: '0.3px', + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' }, +}; + +export default function App() { + const [tab, setTab] = useState('dashboard'); + const [showReadme, setShowReadme] = useState(false); + + return ( + +
+ + +
+ {tab === 'dashboard' ? : } +
+ + {showReadme && setShowReadme(false)} />} +
+
+ ); +} -- 2.52.0 From 7326ffec6eaf61a1d21b9cb93ae3cfe66e8cc79a Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:32:35 -0600 Subject: [PATCH 25/54] docs: update README with Phase 8 features, expanded roadmap with effort ratings --- README.md | 120 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9e3f23f..891be75 100755 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Single-container Dockerized web app for CPAS violation documentation and workforce standing management. Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation). +> © Jason Stedwell · [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas) + --- ## The only requirement on your machine: Docker Desktop @@ -113,6 +115,14 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). --- +## Stakeholder Demo + +A standalone demo page with synthetic data is available at `/demo` (e.g. `http://localhost:3001/demo`). +It is served as a static route before the SPA catch-all and requires no authentication. +Useful for showing the app to stakeholders without exposing live employee data. + +--- + ## Features ### Company Dashboard @@ -120,8 +130,9 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). - Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score - **At-risk badge**: flags employees within 2 points of the next tier escalation - Search/filter by name, department, or supervisor +- **Department filter**: pre-loaded dropdown of all departments for quick scoped views - Click any employee name to open their full profile modal -- **🔍 Audit Log** button — filterable, paginated view of all system write actions +- **📋 Audit Log** button — filterable, paginated view of all system write actions ### Violation Form - Select existing employee or enter new employee by name @@ -170,6 +181,11 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). - Slide-in animation; stacks up to 5 notifications simultaneously - Consistent dark theme styling matching the rest of the UI +### App Footer +- **© Jason Stedwell** copyright with auto-advancing year +- **Live dev ticker**: real-time elapsed counter since first commit (`2026-03-06`), ticking every second in `Xd HHh MMm SSs` format with a pulsing green dot +- **Gitea repo link** with icon — links directly to `git.alwisp.com/jason/cpas` + ### CPAS Tier System | Points | Tier | Label | @@ -209,7 +225,7 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude | GET | `/api/dashboard` | All employees with active points + violation counts | | 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/negated` | Negate a violation (soft delete + resolution record) | | PATCH | `/api/violations/:id/restore` | Restore a negated violation | | PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging | | GET | `/api/violations/:id/amendments` | Get amendment history for a violation | @@ -223,42 +239,43 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude ``` cpas/ -├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium +├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium ├── .dockerignore -├── package.json # Backend (Express) deps -├── server.js # API + static file server +├── package.json # Backend (Express) deps +├── server.js # API + static file server ├── db/ -│ ├── schema.sql # Tables + 90-day active score view +│ ├── schema.sql # Tables + 90-day active score view │ └── database.js # SQLite connection (better-sqlite3) + auto-migrations ├── pdf/ │ ├── generator.js # Puppeteer PDF generation │ └── template.js # HTML template (loads logo from disk, ack signature rendering) +├── demo/ # Static stakeholder demo page (served at /demo) └── client/ # React frontend (Vite) ├── package.json ├── vite.config.js ├── index.html └── src/ ├── main.jsx - ├── App.jsx + ├── App.jsx # Root app + AppFooter (copyright, dev ticker, Gitea link) ├── data/ - │ └── violations.js # All CPAS violation definitions + groups + │ └── violations.js # All CPAS violation definitions + groups ├── hooks/ - │ └── useEmployeeIntelligence.js # Score + history hook + │ └── useEmployeeIntelligence.js # Score + history hook └── components/ - ├── 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 + ack signature fields - ├── EmployeeModal.jsx # Employee profile + history modal - ├── EditEmployeeModal.jsx # Employee edit + merge duplicate - ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history - ├── AuditLog.jsx # Filterable audit log panel - ├── NegateModal.jsx # Negate/resolve violation dialog - ├── 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 + ├── 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 + ack signature fields + ├── EmployeeModal.jsx # Employee profile + history modal + ├── EditEmployeeModal.jsx # Employee edit + merge duplicate + ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history + ├── AuditLog.jsx # Filterable audit log panel + ├── NegateModal.jsx # Negate/resolve violation dialog + ├── 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 ``` --- @@ -319,32 +336,65 @@ Point values, violation type, and incident date are **immutable** after submissi | 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 | +| 7 | Department dropdown | Pre-loaded select on the violation form replacing free-text department input; shared `DEPARTMENTS` constant | +| 8 | Stakeholder demo page | Standalone `/demo` route with synthetic data; static HTML served before SPA catch-all; useful for non-live presentations | +| 8 | App footer | Copyright (© Jason Stedwell), live dev ticker since first commit, Gitea repo icon+link | --- ### 📋 Proposed +Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High + +#### Quick Wins (High value, low effort) + +| Feature | Effort | Description | +|---------|--------|-------------| +| Column sort on dashboard | 🟢 | Click `Tier`, `Active Points`, or `Department` headers to sort in-place; one `useState` + comparator, no API changes | +| Department filter on dashboard | 🟢 | Multi-select dropdown to scope the employee table by department; `DEPARTMENTS` constant already exists | +| Keyboard shortcut: New Violation | 🟢 | `N` key triggers tab switch to the violation form; ~5 lines of code | +| CSV export of dashboard | 🟢 | Client-side Blob download of the current filtered employee view; no backend changes needed | + #### Reporting & Analytics -- **Violation trends chart** — line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns vs. individual incidents -- **Department heat map** — grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk -- **CSV / Excel export** — bulk export of violations or dashboard data for external reporting or payroll integration + +| Feature | Effort | Description | +|---------|--------|-------------| +| Violation trend chart | 🟡 | Line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns | +| Department heat map | 🟡 | Grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk | +| Violation sparklines per employee | 🟡 | Tiny inline bar chart of points over the last 6 months in the employee modal | +| CSV / Excel bulk export | 🟡 | Full export of violations or dashboard data for external reporting or payroll integration | #### Employee Management -- **Supervisor view** — scoped dashboard showing only the employees under a given supervisor, useful for multi-supervisor environments + +| Feature | Effort | Description | +|---------|--------|-------------| +| Supervisor scoped view | 🟡 | Dashboard filtered to a supervisor's direct reports, accessible via URL param (`?supervisor=Name`); no schema changes required | +| Employee photo / avatar | 🟢 | Optional avatar upload stored alongside the employee record; shown in the profile modal and dashboard row | #### Violation Workflow -- **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 + +| Feature | Effort | Description | +|---------|--------|-------------| +| 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 | +| Violation templates | 🟢 | Pre-fill the form with a saved violation type + common details for frequently logged incidents | #### Notifications & Escalation -- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed -- **Scheduled summary digest** — weekly email to supervisors listing their employees' current standings and any approaching tier thresholds -- **At-risk threshold configuration** — make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment + +| Feature | Effort | Description | +|---------|--------|-------------| +| Scheduled expiration digest | 🟡 | Weekly or daily email listing violations rolling off in the next 7 days; `nodemailer` + cron on the Node server | +| Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed | +| At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var | +| version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` | #### Infrastructure & Ops -- **Multi-user auth** — simple login with role-based access (admin, supervisor, read-only); currently the app has no auth and is assumed to run on a trusted internal network -- **Automated DB backup** — cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule -- **Dark/light theme toggle** — the UI is currently dark-only; a toggle would improve usability in bright environments + +| Feature | Effort | Description | +|---------|--------|-------------| +| Multi-user auth | 🔴 | Simple login with role-based access (admin, supervisor, read-only); currently the app runs on a trusted internal network with no auth | +| Automated DB backup | 🟡 | Cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule | +| Dark/light theme toggle | 🟡 | The UI is currently dark-only; a toggle would improve usability in bright environments | --- -- 2.52.0 From 981fa3bea44cb8c79cb08976ad2de320788c53bb Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:35:35 -0600 Subject: [PATCH 26/54] feat: add footer with copyright, live dev ticker, and Gitea repo link --- client/src/App.jsx | 101 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index b3645c2..37cb06d 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,17 +1,78 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import ViolationForm from './components/ViolationForm'; import Dashboard from './components/Dashboard'; import ReadmeModal from './components/ReadmeModal'; import ToastProvider from './components/ToastProvider'; +const REPO_URL = 'https://git.alwisp.com/jason/cpas'; +const PROJECT_START = new Date('2026-03-06T11:33:32-06:00'); + +function elapsed(from) { + const totalSec = Math.floor((Date.now() - from.getTime()) / 1000); + const d = Math.floor(totalSec / 86400); + const h = Math.floor((totalSec % 86400) / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`; +} + +function DevTicker() { + const [tick, setTick] = useState(() => elapsed(PROJECT_START)); + useEffect(() => { + const id = setInterval(() => setTick(elapsed(PROJECT_START)), 1000); + return () => clearInterval(id); + }, []); + return ( + + + {tick} + + ); +} + +function GiteaIcon() { + return ( + + + + ); +} + +function AppFooter() { + const year = new Date().getFullYear(); + return ( + <> + +
+ © {year} Jason Stedwell + · + + · + + cpas + +
+ + ); +} + const tabs = [ { id: 'dashboard', label: '📊 Dashboard' }, { id: 'violation', label: '+ New Violation' }, ]; const s = { - app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa' }, - nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, + app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' }, + nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' }, logoImg: { height: '28px', marginRight: '10px' }, logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' }, @@ -22,7 +83,6 @@ const s = { cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', background: 'none', border: 'none', }), - // Docs button sits flush-right in the nav docsBtn: { marginLeft: 'auto', background: 'none', @@ -38,9 +98,34 @@ const s = { alignItems: 'center', gap: '6px', }, + main: { flex: 1 }, card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' }, }; +const sf = { + footer: { + borderTop: '1px solid #1a1b22', + padding: '12px 40px', + display: 'flex', + alignItems: 'center', + gap: '12px', + fontSize: '11px', + color: 'rgba(248,249,250,0.35)', + background: '#000', + flexShrink: 0, + }, + copy: { color: 'rgba(248,249,250,0.35)' }, + sep: { color: 'rgba(248,249,250,0.15)' }, + link: { + color: 'rgba(248,249,250,0.35)', + textDecoration: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + transition: 'color 0.15s', + }, +}; + export default function App() { const [tab, setTab] = useState('dashboard'); const [showReadme, setShowReadme] = useState(false); @@ -65,10 +150,14 @@ export default function App() { -
- {tab === 'dashboard' ? : } +
+
+ {tab === 'dashboard' ? : } +
+ + {showReadme && setShowReadme(false)} />}
-- 2.52.0 From 995e6070036ec773ed51de99e427dd5eb1fbd051 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:38:38 -0600 Subject: [PATCH 27/54] fix: remove default browser body margin causing white border --- client/index.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/index.html b/client/index.html index 2a311d7..a72bce6 100755 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,11 @@ CPAS Violation Tracker +
-- 2.52.0 From 87cf48e77e29bbd773dce5801164418132c9ed0c Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:42:48 -0600 Subject: [PATCH 28/54] Update README.md --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 891be75..db9a258 100755 --- a/README.md +++ b/README.md @@ -353,7 +353,6 @@ Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High | Column sort on dashboard | 🟢 | Click `Tier`, `Active Points`, or `Department` headers to sort in-place; one `useState` + comparator, no API changes | | Department filter on dashboard | 🟢 | Multi-select dropdown to scope the employee table by department; `DEPARTMENTS` constant already exists | | Keyboard shortcut: New Violation | 🟢 | `N` key triggers tab switch to the violation form; ~5 lines of code | -| CSV export of dashboard | 🟢 | Client-side Blob download of the current filtered employee view; no backend changes needed | #### Reporting & Analytics @@ -362,7 +361,6 @@ Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High | Violation trend chart | 🟡 | Line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns | | Department heat map | 🟡 | Grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk | | Violation sparklines per employee | 🟡 | Tiny inline bar chart of points over the last 6 months in the employee modal | -| CSV / Excel bulk export | 🟡 | Full export of violations or dashboard data for external reporting or payroll integration | #### Employee Management @@ -376,14 +374,12 @@ Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High | Feature | Effort | Description | |---------|--------|-------------| | 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 | | Violation templates | 🟢 | Pre-fill the form with a saved violation type + common details for frequently logged incidents | #### Notifications & Escalation | Feature | Effort | Description | |---------|--------|-------------| -| Scheduled expiration digest | 🟡 | Weekly or daily email listing violations rolling off in the next 7 days; `nodemailer` + cron on the Node server | | Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed | | At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var | | version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` | -- 2.52.0 From b02026f8a92f2e1e6ecd18f82e36cdaf920b4620 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:45:49 -0600 Subject: [PATCH 29/54] feat: inject git SHA + build timestamp into version.json during docker build --- Dockerfile | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 18091ea..5aa27ad 100755 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,15 @@ RUN cd client && npm install COPY client/ ./client/ RUN cd client && npm run build +# ── Version metadata ────────────────────────────────────────────────────────── +# Pass these at build time: +# docker build --build-arg GIT_SHA=$(git rev-parse HEAD) \ +# --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) . +ARG GIT_SHA=dev +ARG BUILD_TIME=unknown +RUN echo "{\"sha\":\"${GIT_SHA}\",\"shortSha\":\"${GIT_SHA:0:7}\",\"buildTime\":\"${BUILD_TIME}\"}" \ + > /build/client/dist/version.json + FROM node:20-alpine AS production RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true @@ -25,5 +34,6 @@ COPY demo/ ./demo/ COPY client/public/static ./client/dist/static RUN mkdir -p /data EXPOSE 3001 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3001/api/health || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 CMD ["node", "server.js"] -- 2.52.0 From 20be30318f30c16b98a595f7f3202e71f6e6a974 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:45:53 -0600 Subject: [PATCH 30/54] feat: add local dev fallback version.json stub --- client/public/version.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 client/public/version.json diff --git a/client/public/version.json b/client/public/version.json new file mode 100644 index 0000000..d23d125 --- /dev/null +++ b/client/public/version.json @@ -0,0 +1,5 @@ +{ + "sha": "dev", + "shortSha": "dev", + "buildTime": null +} -- 2.52.0 From 51bf176f96c4d298c201544ded31fc2df17a1512 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:54:58 -0600 Subject: [PATCH 31/54] feat: load version.json at startup, expose via /api/health --- server.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index d1dc66b..e4a220e 100755 --- a/server.js +++ b/server.js @@ -29,8 +29,19 @@ function audit(action, entityType, entityId, performedBy, details) { } } +// ── Version info (written by Dockerfile at build time) ─────────────────────── +// Falls back to { sha: 'dev' } when running outside a Docker build (local dev). +let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null }; +try { + BUILD_VERSION = require('./client/dist/version.json'); +} catch (_) { /* pre-build or local dev — stub values are fine */ } + // Health -app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); +app.get('/api/health', (req, res) => res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: BUILD_VERSION, +})); // ── Employees ──────────────────────────────────────────────────────────────── app.get('/api/employees', (req, res) => { -- 2.52.0 From f4ed8c49ce79d564e3f5f46469ccb3d977f1baa7 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 00:55:44 -0600 Subject: [PATCH 32/54] feat: fetch version.json on mount, show short SHA + commit link in footer --- client/src/App.jsx | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 37cb06d..0055663 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,8 +42,13 @@ function GiteaIcon() { ); } -function AppFooter() { +function AppFooter({ version }) { const year = new Date().getFullYear(); + const sha = version?.shortSha || null; + const built = version?.buildTime + ? new Date(version.buildTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : null; + return ( <>
- © {year} Jason Stedwell - · + © {year} Jason Stedwell + · - · + · cpas + {sha && sha !== 'dev' && ( + <> + · + + {sha} + + + )}
); @@ -129,6 +148,14 @@ const sf = { export default function App() { const [tab, setTab] = useState('dashboard'); const [showReadme, setShowReadme] = useState(false); + const [version, setVersion] = useState(null); + + useEffect(() => { + fetch('/version.json') + .then(r => r.ok ? r.json() : null) + .then(v => { if (v) setVersion(v); }) + .catch(() => {}); + }, []); return ( @@ -156,7 +183,7 @@ export default function App() {
- + {showReadme && setShowReadme(false)} />} -- 2.52.0 From 602f371d67c90e684e6cfa04e310a85a0e431d91 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 22:02:10 -0500 Subject: [PATCH 33/54] Add mobile-responsive CSS utility file --- client/src/styles/mobile.css | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 client/src/styles/mobile.css diff --git a/client/src/styles/mobile.css b/client/src/styles/mobile.css new file mode 100644 index 0000000..89c02f4 --- /dev/null +++ b/client/src/styles/mobile.css @@ -0,0 +1,113 @@ +/* Mobile-Responsive Utilities for CPAS Tracker */ +/* Target: Standard phones 375px+ with graceful degradation */ + +/* Base responsive utilities */ +@media (max-width: 768px) { + /* Hide scrollbars but keep functionality */ + * { + -webkit-overflow-scrolling: touch; + } + + /* Touch-friendly tap targets (min 44px) */ + button, a, input, select { + min-height: 44px; + } + + /* Improve form input sizing on mobile */ + input, select, textarea { + font-size: 16px !important; /* Prevents iOS zoom on focus */ + } +} + +/* Tablet and below */ +@media (max-width: 1024px) { + .hide-tablet { + display: none !important; + } +} + +/* Mobile portrait and landscape */ +@media (max-width: 768px) { + .hide-mobile { + display: none !important; + } + + .mobile-full-width { + width: 100% !important; + } + + .mobile-text-center { + text-align: center !important; + } + + .mobile-no-padding { + padding: 0 !important; + } + + .mobile-small-padding { + padding: 12px !important; + } + + /* Stack flex containers vertically */ + .mobile-stack { + flex-direction: column !important; + } + + /* Allow horizontal scroll for tables */ + .mobile-scroll-x { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + } + + /* Card-based layout helpers */ + .mobile-card { + display: block !important; + padding: 16px; + margin-bottom: 12px; + border-radius: 8px; + background: #181924; + border: 1px solid #2a2b3a; + } + + .mobile-card-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #1c1d29; + } + + .mobile-card-row:last-child { + border-bottom: none; + } + + .mobile-card-label { + font-weight: 600; + color: #9ca0b8; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .mobile-card-value { + font-weight: 600; + color: #f8f9fa; + text-align: right; + } +} + +/* Small mobile phones */ +@media (max-width: 480px) { + .hide-small-mobile { + display: none !important; + } +} + +/* Utility for sticky positioning on mobile */ +@media (max-width: 768px) { + .mobile-sticky-top { + position: sticky; + top: 0; + z-index: 100; + background: #000000; + } +} -- 2.52.0 From 74492142a14a15b1d1eb810634b164119f67d051 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 22:04:05 -0500 Subject: [PATCH 34/54] Update App.jsx with mobile-responsive navigation and layout --- client/src/App.jsx | 105 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 12 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 0055663..2b02499 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import ViolationForm from './components/ViolationForm'; import Dashboard from './components/Dashboard'; import ReadmeModal from './components/ReadmeModal'; import ToastProvider from './components/ToastProvider'; +import './styles/mobile.css'; const REPO_URL = 'https://git.alwisp.com/jason/cpas'; const PROJECT_START = new Date('2026-03-06T11:33:32-06:00'); @@ -56,8 +57,19 @@ function AppFooter({ version }) { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.75); } } + + /* Mobile-specific footer adjustments */ + @media (max-width: 768px) { + .footer-content { + flex-wrap: wrap; + justify-content: center; + font-size: 10px; + padding: 10px 16px; + gap: 8px; + } + } `} -