roadmap #27

Merged
jason merged 2 commits from roadmap into master 2026-03-07 18:56:43 -06:00
2 changed files with 155 additions and 150 deletions

128
README.md
View File

@@ -27,14 +27,6 @@ docker run -d --name cpas-tracker \
# http://localhost:3001 # 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 ## Update After Code Changes
```bash ```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 ## Features
### Company Dashboard ### Company Dashboard
@@ -143,16 +211,16 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
``` ```
cpas/ cpas/
├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium ├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium
├── .dockerignore ├── .dockerignore
├── package.json # Backend (Express) deps ├── package.json # Backend (Express) deps
├── server.js # API + static file server ├── server.js # API + static file server
├── db/ ├── 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 │ └── database.js # SQLite connection (better-sqlite3) + auto-migrations
├── pdf/ ├── pdf/
│ └── generator.js # Puppeteer PDF generation │ └── generator.js # Puppeteer PDF generation
└── client/ # React frontend (Vite) └── client/ # React frontend (Vite)
├── package.json ├── package.json
├── vite.config.js ├── vite.config.js
├── index.html ├── index.html
@@ -160,23 +228,23 @@ cpas/
├── main.jsx ├── main.jsx
├── App.jsx ├── App.jsx
├── data/ ├── data/
│ └── violations.js # All CPAS violation definitions + groups │ └── violations.js # All CPAS violation definitions + groups
├── hooks/ ├── hooks/
│ └── useEmployeeIntelligence.js # Score + history hook │ └── useEmployeeIntelligence.js # Score + history hook
└── components/ └── components/
├── CpasBadge.jsx # Tier badge + color logic ├── CpasBadge.jsx # Tier badge + color logic
├── TierWarning.jsx # Pre-submit tier crossing alert ├── TierWarning.jsx # Pre-submit tier crossing alert
├── Dashboard.jsx # Company-wide leaderboard + audit log trigger ├── Dashboard.jsx # Company-wide leaderboard + audit log trigger
├── ViolationForm.jsx # Violation entry form ├── ViolationForm.jsx # Violation entry form
├── EmployeeModal.jsx # Employee profile + history modal ├── EmployeeModal.jsx # Employee profile + history modal
├── EditEmployeeModal.jsx # Employee edit + merge duplicate ├── EditEmployeeModal.jsx # Employee edit + merge duplicate
├── AmendViolationModal.jsx # Non-scoring field amendment + diff history ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history
├── AuditLog.jsx # Filterable audit log panel ├── AuditLog.jsx # Filterable audit log panel
├── NegateModal.jsx # Negate/resolve violation dialog ├── NegateModal.jsx # Negate/resolve violation dialog
├── ViolationHistory.jsx # Violation list component ├── ViolationHistory.jsx # Violation list component
├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown
├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags
└── ReadmeModal.jsx # In-app admin documentation panel └── ReadmeModal.jsx # In-app admin documentation panel
``` ```
--- ---

View File

@@ -1,17 +1,16 @@
import React, { useEffect, useRef } from 'react'; 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) { function mdToHtml(md) {
const lines = md.split('\n'); const lines = md.split('\n');
const out = []; const out = [];
let i = 0, inUl = false, inOl = false, inTable = false; let i = 0, inUl = false, inOl = false, inTable = false;
const close = () => { const close = () => {
if (inUl) { out.push('</ul>'); inUl = false; } if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; } if (inOl) { out.push('</ol>'); inOl = false; }
if (inTable) { out.push('</tbody></table>'); inTable = false; } if (inTable) { out.push('</tbody></table>'); inTable = false; }
}; };
const inline = s => const inline = s =>
s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
@@ -19,121 +18,72 @@ function mdToHtml(md) {
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; 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('<hr>'); i++; continue; } if (/^---+$/.test(line.trim())) { close(); out.push('<hr>'); i++; continue; }
const hm = line.match(/^(#{1,4})\s+(.+)/); const hm = line.match(/^(#{1,4})\s+(.+)/);
if (hm) { if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; }
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;
}
if (line.trim().startsWith('|')) { 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) { if (!inTable) { close(); inTable=true; out.push('<table><thead><tr>'); cells.forEach(c=>out.push(`<th>${inline(c)}</th>`)); out.push('</tr></thead><tbody>'); i++; if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++; continue; }
close(); inTable = true; else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); i++; continue; }
out.push('<table><thead><tr>');
cells.forEach(c => out.push(`<th>${inline(c)}</th>`));
out.push('</tr></thead><tbody>');
i++;
if (i < lines.length && /^[\|\s\-:]+$/.test(lines[i])) i++;
continue;
} else {
out.push('<tr>');
cells.forEach(c => out.push(`<td>${inline(c)}</td>`));
out.push('</tr>');
i++; continue;
}
} }
const ul = line.match(/^[-*]\s+(.*)/); const ul = line.match(/^[-*]\s+(.*)/);
if (ul) { 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; }
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;
}
const ol = line.match(/^\d+\.\s+(.*)/); const ol = line.match(/^\d+\.\s+(.*)/);
if (ol) { 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; }
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;
}
if (line.trim() === '') { close(); i++; continue; } if (line.trim() === '') { close(); i++; continue; }
close(); out.push(`<p>${inline(line)}</p>`); i++;
close();
out.push(`<p>${inline(line)}</p>`);
i++;
} }
close(); close();
return out.join('\n'); 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 ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const S = { const S = {
overlay: { overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' },
position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', 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' },
zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end', 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 },
panel: { toc: { background:'#0d1117', borderBottom:'1px solid #1e1f2e', padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px' },
background:'#111217', color:'#f8f9fa', width:'780px', maxWidth:'95vw', body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' },
height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' },
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 = ` 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 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 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 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 h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px }
.adm p { color:#c8ccd8; margin:5px 0 10px; } .adm p { color:#c8ccd8; margin:5px 0 10px }
.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0; } .adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0 }
.adm strong { color:#f8f9fa; } .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 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 ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
.adm ol { 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 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 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 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 td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8 }
.adm tr:last-child td { border-bottom:none; } .adm tr:last-child td { border-bottom:none }
.adm tr:hover td { background:#1e1f2e; } .adm tr:hover td { background:#1e1f2e }
`; `;
// ─── Admin guide content ────────────────────────────────────────────────────── // ─── Admin guide content (no install / Docker content) ────────────────────────
const GUIDE_MD = `# CPAS Tracker — Admin Guide 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. 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. 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. 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 - **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 - **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) - **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 ## Roadmap
### Shipped ### Shipped
- Container scaffold, violation form, employee intelligence - Container scaffold, violation form, employee intelligence
- Recidivist auto-escalation, tier crossing warning - 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. 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. 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. 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. - **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. - **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. - **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. - **Bulk CSV import** — migrate historical violation records from paper logs or a prior system.
- **Dark/light theme toggle** — UI is currently dark-only. - **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 ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export default function ReadmeModal({ onClose }) { export default function ReadmeModal({ onClose }) {
const bodyRef = useRef(null); const bodyRef = useRef(null);
@@ -342,7 +283,7 @@ export default function ReadmeModal({ onClose }) {
📋 CPAS Tracker Admin Guide 📋 CPAS Tracker Admin Guide
</div> </div>
<div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}> <div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}>
Feature map · workflow reference · roadmap · Esc or click outside to close Feature map · workflows · roadmap · Esc or click outside to close
</div> </div>
</div> </div>
<button style={S.closeBtn} onClick={onClose} aria-label="Close"></button> <button style={S.closeBtn} onClick={onClose} aria-label="Close"></button>
@@ -351,16 +292,12 @@ export default function ReadmeModal({ onClose }) {
{/* TOC strip */} {/* TOC strip */}
<div style={S.toc}> <div style={S.toc}>
{toc.map(h => ( {toc.map(h => (
<button <button key={h.id} onClick={() => scrollTo(h.id)} style={{
key={h.id} background:'none', border:'none', cursor:'pointer', padding:'3px 0',
onClick={() => scrollTo(h.id)} color: h.level === 1 ? '#f8f9fa' : '#d4af37',
style={{ fontWeight: h.level === 1 ? 700 : 500,
background:'none', border:'none', cursor:'pointer', padding:'2px 0', fontSize:'11px',
color: h.level === 1 ? '#f8f9fa' : '#d4af37', }}>
fontWeight: h.level === 1 ? 700 : 500,
fontSize:'11px',
}}
>
{h.level === 2 ? '↳ ' : ''}{h.text} {h.level === 2 ? '↳ ' : ''}{h.text}
</button> </button>
))} ))}