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(``)); - 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 => ( - ))}
${inline(c)}
${inline(c)}