feat: ReadmeModal — admin usage guide, feature map, workflow reference, roadmap (no install content)

This commit is contained in:
2026-03-07 18:39:01 -06:00
parent 554b39672f
commit 281825377f

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>
))} ))}