roadmap #27
128
README.md
128
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')
|
s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
.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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user