Merge pull request 'roadmap' (#26) from roadmap into master

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-03-07 18:35:43 -06:00
2 changed files with 307 additions and 353 deletions

View File

@@ -53,7 +53,7 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
- **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
- **🔍 Audit Log** button — filterable, paginated view of all system write actions
### Violation Form
- Select existing employee or enter new employee by name
@@ -68,12 +68,14 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
### Employee Profile Modal
- Full violation history with resolution status and **amendment count badge** per record
- **✎ Edit Employee** button — update name, department, or supervisor inline
- **✎ 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
@@ -85,6 +87,11 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
- 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 |
@@ -112,21 +119,23 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/employees` | List all employees |
| GET | `/api/employees` | List all employees (includes `notes`) |
| POST | `/api/employees` | Create or upsert employee |
| PATCH | `/api/employees/:id` | Edit employee name, department, or supervisor |
| POST | `/api/employees/:id/merge` | Merge duplicate employee into target; reassigns all violations |
| 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` | Get violation history for employee (with resolutions + amendment counts) |
| GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts |
| PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) |
| PATCH | `/api/violations/:id/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) |
| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) |
---
@@ -134,16 +143,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
@@ -151,20 +160,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
├── 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
```
---
@@ -173,7 +185,7 @@ cpas/
Six tables + one view:
- **`employees`** — id, name, department, supervisor
- **`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
@@ -182,6 +194,20 @@ Six tables + one view:
---
## 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
@@ -204,18 +230,13 @@ Six tables + one view:
| 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 |
---
### 📋 In Progress
#### Reporting & Visibility
- **Expiration timeline** — per-employee view showing which active violations roll off the 90-day window and when; lets supervisors anticipate tier drops before they happen
- **Employee notes / flags** — free-text notes attached to an employee record (e.g. "on PIP", "union member") visible in the profile modal without affecting scoring
---
### 💡 Proposed
### 📋 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

View File

@@ -1,286 +1,145 @@
import React, { useEffect, useRef } from 'react';
// ─── Minimal Markdown → HTML renderer ────────────────────────────────────────
// Handles: headings, bold, inline-code, fenced code blocks, tables, hr,
// unordered lists, ordered lists, and paragraphs.
function mdToHtml(md) {
const lines = md.split('\n');
const out = [];
let i = 0;
let inUl = false;
let inOl = false;
let inTable = false;
let tableHead = false;
const lines = md.split('\n');
const out = [];
let i = 0, inUl = false, inOl = false, inTable = false;
const closeOpenLists = () => {
if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; }
if (inTable) { out.push('</tbody></table>'); inTable = false; tableHead = false; }
const close = () => {
if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; }
if (inTable) { out.push('</tbody></table>'); inTable = false; }
};
const inline = (s) =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
const inline = s =>
s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/`([^`]+)`/g,'<code>$1</code>');
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
closeOpenLists();
const lang = line.slice(3).trim();
const codeLines = [];
close();
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i].replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'));
i++;
}
out.push(`<pre><code class="lang-${lang}">${codeLines.join('\n')}</code></pre>`);
i++;
continue;
while (i < lines.length && !lines[i].startsWith('```')) i++;
i++; continue;
}
if (/^---+$/.test(line.trim())) { close(); out.push('<hr>'); 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(`<h${lvl} id="${id}">${inline(hm[2])}</h${lvl}>`);
i++; continue;
}
// HR
if (/^---+$/.test(line.trim())) {
closeOpenLists();
out.push('<hr>');
i++;
continue;
}
// Headings
const hMatch = line.match(/^(#{1,4})\s+(.+)/);
if (hMatch) {
closeOpenLists();
const level = hMatch[1].length;
const id = hMatch[2].toLowerCase().replace(/[^a-z0-9]+/g, '-');
out.push(`<h${level} id="${id}">${inline(hMatch[2])}</h${level}>`);
i++;
continue;
}
// Table row
if (line.trim().startsWith('|')) {
const cells = line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
const cells = line.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim());
if (!inTable) {
closeOpenLists();
inTable = true;
tableHead = true;
close(); inTable = true;
out.push('<table><thead><tr>');
cells.forEach(c => out.push(`<th>${inline(c)}</th>`));
out.push('</tr></thead><tbody>');
i++;
// skip separator row
if (i < lines.length && lines[i].trim().startsWith('|') && /^[\|\s\-:]+$/.test(lines[i])) i++;
if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++;
continue;
} else {
out.push('<tr>');
cells.forEach(c => out.push(`<td>${inline(c)}</td>`));
out.push('</tr>');
i++;
continue;
i++; continue;
}
}
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.*)/);
if (ulMatch) {
if (inTable) closeOpenLists();
if (!inUl) { if (inOl) { out.push('</ol>'); inOl = false; } out.push('<ul>'); inUl = true; }
out.push(`<li>${inline(ulMatch[1])}</li>`);
i++;
continue;
const ul = line.match(/^[-*]\s+(.*)/);
if (ul) {
if (inTable) close();
if (!inUl) { if (inOl) { out.push('</ol>'); inOl=false; } out.push('<ul>'); inUl=true; }
out.push(`<li>${inline(ul[1])}</li>`);
i++; continue;
}
// Ordered list
const olMatch = line.match(/^\d+\.\s+(.*)/);
if (olMatch) {
if (inTable) closeOpenLists();
if (!inOl) { if (inUl) { out.push('</ul>'); inUl = false; } out.push('<ol>'); inOl = true; }
out.push(`<li>${inline(olMatch[1])}</li>`);
i++;
continue;
const ol = line.match(/^\d+\.\s+(.*)/);
if (ol) {
if (inTable) close();
if (!inOl) { if (inUl) { out.push('</ul>'); inUl=false; } out.push('<ol>'); inOl=true; }
out.push(`<li>${inline(ol[1])}</li>`);
i++; continue;
}
// Blank line
if (line.trim() === '') {
closeOpenLists();
i++;
continue;
}
if (line.trim() === '') { close(); i++; continue; }
// Paragraph
closeOpenLists();
close();
out.push(`<p>${inline(line)}</p>`);
i++;
}
closeOpenLists();
close();
return out.join('\n');
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const overlay = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
zIndex: 2000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
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' },
};
const panel = {
background: '#111217', color: '#f8f9fa', width: '760px', maxWidth: '95vw',
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 32px rgba(0,0,0,0.8)',
display: 'flex', flexDirection: 'column',
};
const 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',
};
const closeBtn = {
background: 'none', border: 'none', color: 'white',
fontSize: '22px', cursor: 'pointer', lineHeight: 1,
};
const body = {
padding: '28px 32px', flex: 1, fontSize: '13px', lineHeight: '1.7',
};
// Injected <style> for rendered markdown elements
const CSS = `
.readme-body h1 { font-size: 22px; font-weight: 800; color: #f8f9fa; margin: 28px 0 10px; border-bottom: 1px solid #2a2b3a; padding-bottom: 8px; }
.readme-body h2 { font-size: 17px; font-weight: 700; color: #d4af37; margin: 26px 0 8px; }
.readme-body h3 { font-size: 14px; font-weight: 700; color: #90caf9; margin: 20px 0 6px; text-transform: uppercase; letter-spacing: 0.4px; }
.readme-body h4 { font-size: 13px; font-weight: 700; color: #9ca0b8; margin: 14px 0 4px; }
.readme-body p { color: #c8ccd8; margin: 6px 0 10px; }
.readme-body hr { border: none; border-top: 1px solid #2a2b3a; margin: 20px 0; }
.readme-body strong { color: #f8f9fa; }
.readme-body code {
background: #0d1117; color: #79c0ff; border: 1px solid #2a2b3a;
border-radius: 4px; padding: 1px 6px; font-family: 'Consolas', 'Fira Code', monospace; font-size: 12px;
}
.readme-body pre {
background: #0d1117; border: 1px solid #2a2b3a; border-radius: 6px;
padding: 14px 16px; overflow-x: auto; margin: 10px 0 16px;
}
.readme-body pre code {
background: none; border: none; padding: 0; color: #e6edf3;
font-size: 12px; line-height: 1.6;
}
.readme-body ul { padding-left: 22px; margin: 6px 0 10px; color: #c8ccd8; }
.readme-body ol { padding-left: 22px; margin: 6px 0 10px; color: #c8ccd8; }
.readme-body li { margin: 3px 0; }
.readme-body table {
width: 100%; border-collapse: collapse; font-size: 12px;
background: #181924; border-radius: 6px; overflow: hidden;
border: 1px solid #2a2b3a; margin: 10px 0 16px;
}
.readme-body 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;
}
.readme-body td {
padding: 8px 12px; border-bottom: 1px solid #202231; color: #c8ccd8;
}
.readme-body tr:last-child td { border-bottom: none; }
.readme-body 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; }
`;
// ─── Table-of-contents builder ────────────────────────────────────────────────
function buildToc(md) {
const headings = [];
for (const line of md.split('\n')) {
const m = line.match(/^(#{1,3})\s+(.+)/);
if (m) {
headings.push({
level: m[1].length,
text: m[2],
id: m[2].toLowerCase().replace(/[^a-z0-9]+/g, '-'),
});
}
}
return headings;
}
// ─── Admin guide content ──────────────────────────────────────────────────────
const GUIDE_MD = `# CPAS Tracker — Admin Guide
// ─── README content ───────────────────────────────────────────────────────────
// Embedded at build time — no extra fetch needed.
const README_MD = `# 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).
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.
---
## Quickstart (Local)
## How CPAS Scoring Works
\`\`\`bash
# 1. Build the image
docker build -t cpas-tracker .
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.
# 2. Run it
docker run -d --name cpas-tracker \\
-p 3001:3001 \\
-v cpas-data:/data \\
cpas-tracker
\`\`\`
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
Open **http://localhost:3001**
## 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
\`\`\`
---
## Features
### Company Dashboard
- Live employee table sorted by active CPAS points (highest risk first)
- Stat cards: total employees, elite standing, active points, at-risk count, highest score
- **At-risk badge** — flags employees within 2 points of the next tier escalation
- Search/filter by name, department, or supervisor
- **Audit Log** button — filterable, paginated view of all system write actions
### Violation Form
- Select existing employee or enter new by name
- **Employee intelligence** — shows current CPAS standing before submitting
- Violation type dropdown grouped by category with 90-day recurrence counts
- **Recidivist auto-escalation** — points slider auto-maximizes on repeat violations
- **Tier crossing warning** — previews tier impact before submission
- One-click PDF download after submission
### Employee Profile Modal
- Full violation history with resolution status and amendment count badges
- **Edit Employee** — update name, department, or supervisor inline
- **Merge Duplicate** — reassign all violations from a duplicate record
- **Amend** per active violation — edit non-scoring fields with full diff history
- Negate / restore individual violations (soft delete with resolution type + notes)
- Hard delete option for data entry errors
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") visible in the profile
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window with tier-drop projections
### Audit Log
- Append-only log of every write action
- Filterable by entity type and action; paginated with load-more
### Violation Amendment
- Edit submitted violations' non-scoring fields without delete-and-resubmit
- Point values, violation type, and incident date are immutable
- Every change stored as a field-level diff (old → new) with timestamp
---
## CPAS Tier System
## Tier Reference
| Points | Tier | Label |
|--------|------|-------|
@@ -292,137 +151,214 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
Scores are computed over a **rolling 90-day window** (negated violations excluded).
The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
---
## API Reference
## Feature Map
| 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 |
| GET | \`/api/employees/:id/score\` | Active CPAS score |
| GET | \`/api/employees/:id/expiration\` | Active violation roll-off timeline |
| PATCH | \`/api/employees/:id/notes\` | Save employee notes only |
| GET | \`/api/dashboard\` | All employees with active points |
| POST | \`/api/violations\` | Log a new violation |
| GET | \`/api/violations/employee/:id\` | Violation history with resolutions + amendment counts |
| PATCH | \`/api/violations/:id/negate\` | Soft delete + resolution record |
| PATCH | \`/api/violations/:id/restore\` | Restore a negated violation |
| PATCH | \`/api/violations/:id/amend\` | Amend non-scoring fields |
| GET | \`/api/violations/:id/amendments\` | Amendment history |
| DELETE | \`/api/violations/:id\` | Hard delete |
| GET | \`/api/violations/:id/pdf\` | Download violation PDF |
| GET | \`/api/audit\` | Paginated audit log |
### Dashboard
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
- **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)
- **Click any name** — opens that employee's full profile modal
---
## Database Schema
### Logging a Violation
Six tables + one view:
Use the **+ New Violation** tab.
- **\`employees\`** — id, name, department, supervisor, **notes**
- **\`violations\`** — full incident record including \`prior_active_points\` snapshot
- **\`violation_resolutions\`** — resolution type, details, resolved_by
- **\`violation_amendments\`** — field-level diff log per amendment
- **\`audit_log\`** — append-only record of every write action
- **\`active_cpas_scores\`** (view) — rolling 90-day score per employee
1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
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.
---
## Amendable Fields
### Employee Profile Modal
The following violation fields can be edited after submission. Points, type, and incident date are **immutable**.
Click any name on the dashboard to open their profile.
| 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 |
#### Overview section
Shows current tier badge, active points, and 90-day violation count.
#### Notes & Flags
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
#### Point Expiration Timeline
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
#### 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.
- **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.
#### Edit Employee
Update name, department, or supervisor. Changes are logged to the audit trail.
#### Merge Duplicate
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
---
## Docker Quick Reference
### Audit Log
\`\`\`bash
# Build
docker build -t cpas-tracker .
Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
# Run
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
- Filter by entity type: **employee** or **violation**
- Filter by action: created, edited, merged, negated, restored, amended, deleted, notes updated
- Paginated with load-more; most recent entries first
# Stop / remove
docker stop cpas-tracker && docker rm cpas-tracker
The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
# Export for Unraid
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
---
# View logs
docker logs -f cpas-tracker
\`\`\`
### Violation Amendment
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.
**Immutable fields:** violation type, incident date, point value.
Each amendment stores a before/after diff for every changed field. Amendment history is accessible from the violation card in the employee's history.
---
## Immutability Rules — Quick Reference
| Action | Allowed? | Notes |
|--------|----------|-------|
| Edit violation type | No | Immutable after submission |
| Edit incident date | No | Immutable after submission |
| Edit point value | No | Immutable after submission |
| Edit location / details / witness | 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 |
| Merge duplicate employees | Yes | Irreversible |
| Add / edit employee notes | Yes | Does not affect score |
---
## Roadmap
### ✅ Shipped
- Container scaffold, violation form, employee intelligence
- Recidivist auto-escalation, tier crossing warning
- PDF generation with prior-points snapshot
- Company dashboard, stat cards, at-risk badges
- Employee profile modal — full history, negate/restore, hard delete
- Employee edit and duplicate merge
- Violation amendment with field-level diff log
- Audit log — filterable, paginated, append-only
- Employee notes and flags with quick-add HR tags
- Point expiration timeline with tier-drop projections
- In-app admin guide (this panel)
---
### 🔜 Near-term
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.
---
### 📋 Planned
Larger features that require more design work or infrastructure.
- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
---
### 🔭 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.
- **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);
const html = mdToHtml(README_MD);
const toc = buildToc(README_MD);
const bodyRef = useRef(null);
const html = mdToHtml(GUIDE_MD);
const toc = buildToc(GUIDE_MD);
// Close on Escape
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
const h = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
const scrollTo = (id) => {
const scrollTo = id => {
const el = bodyRef.current?.querySelector(`#${id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleOverlay = (e) => { if (e.target === e.currentTarget) onClose(); };
return (
<div style={overlay} onClick={handleOverlay}>
{/* Inject markdown CSS once */}
<div style={S.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<style>{CSS}</style>
<div style={panel} onClick={(e) => e.stopPropagation()}>
<div style={S.panel} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={header}>
<div style={S.header}>
<div>
<div style={{ fontSize: '17px', fontWeight: 800, letterSpacing: '0.3px' }}>
📋 CPAS Tracker Documentation
<div style={{ fontSize:'17px', fontWeight:800, letterSpacing:'.3px' }}>
📋 CPAS Tracker Admin Guide
</div>
<div style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '3px' }}>
Admin reference · use Esc or click outside to close
<div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}>
Feature map · workflow reference · roadmap · Esc or click outside to close
</div>
</div>
<button style={closeBtn} onClick={onClose} aria-label="Close"></button>
<button style={S.closeBtn} onClick={onClose} aria-label="Close"></button>
</div>
{/* TOC strip */}
<div style={{
background: '#0d1117', borderBottom: '1px solid #1e1f2e',
padding: '10px 32px', display: 'flex', flexWrap: 'wrap', gap: '4px 16px',
fontSize: '11px',
}}>
{toc.filter(h => h.level <= 2).map((h) => (
<div style={S.toc}>
{toc.map(h => (
<button
key={h.id}
onClick={() => scrollTo(h.id)}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '2px 0',
color: h.level === 1 ? '#f8f9fa' : '#90caf9',
fontWeight: h.level === 1 ? 700 : 400,
fontSize: '11px',
background:'none', border:'none', cursor:'pointer', padding:'2px 0',
color: h.level === 1 ? '#f8f9fa' : '#d4af37',
fontWeight: h.level === 1 ? 700 : 500,
fontSize:'11px',
}}
>
{h.level === 2 ? '↳ ' : ''}{h.text}
@@ -433,16 +369,13 @@ export default function ReadmeModal({ onClose }) {
{/* Body */}
<div
ref={bodyRef}
style={body}
className="readme-body"
style={S.body}
className="adm"
dangerouslySetInnerHTML={{ __html: html }}
/>
{/* Footer */}
<div style={{
padding: '14px 32px', borderTop: '1px solid #1e1f2e',
fontSize: '11px', color: '#555770', textAlign: 'center',
}}>
<div style={S.footer}>
CPAS Violation Tracker · internal admin use only
</div>
</div>