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
```
---
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(`| ${inline(c)} | `));
- out.push('
');
- i++;
- if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++;
- continue;
- } else {
- out.push('');
- cells.forEach(c => out.push(`| ${inline(c)} | `));
- out.push('
');
- i++; continue;
- }
+ if (!inTable) { close(); inTable=true; out.push(''); cells.forEach(c=>out.push(`| ${inline(c)} | `)); out.push('
'); i++; if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++; continue; }
+ else { out.push(''); cells.forEach(c=>out.push(`| ${inline(c)} | `)); 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=true; }
- out.push(`- ${inline(ul[1])}
`);
- i++; continue;
- }
-
+ if (ul) { if (inTable) close(); if (!inUl) { if (inOl){out.push('');inOl=false;} out.push('');inUl=true; } out.push(`- ${inline(ul[1])}
`); i++; continue; }
const ol = line.match(/^\d+\.\s+(.*)/);
- if (ol) {
- if (inTable) close();
- if (!inOl) { if (inUl) { out.push('
'); inUl=false; } out.push(''); inOl=true; }
- out.push(`- ${inline(ol[1])}
`);
- i++; continue;
- }
-
+ if (ol) { if (inTable) close(); if (!inOl) { if (inUl){out.push('
');inUl=false;} out.push('');inOl=true; } out.push(`- ${inline(ol[1])}
`); 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 => (
-