roadmap #25
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import ViolationForm from './components/ViolationForm';
|
import ViolationForm from './components/ViolationForm';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
|
import ReadmeModal from './components/ReadmeModal';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'dashboard', label: '📊 Dashboard' },
|
{ id: 'dashboard', label: '📊 Dashboard' },
|
||||||
@@ -20,11 +21,29 @@ const s = {
|
|||||||
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
||||||
background: 'none', border: 'none',
|
background: 'none', border: 'none',
|
||||||
}),
|
}),
|
||||||
|
// Docs button sits flush-right in the nav
|
||||||
|
docsBtn: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid #2a2b3a',
|
||||||
|
color: '#9ca0b8',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
},
|
||||||
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState('dashboard');
|
const [tab, setTab] = useState('dashboard');
|
||||||
|
const [showReadme, setShowReadme] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.app}>
|
<div style={s.app}>
|
||||||
<nav style={s.nav}>
|
<nav style={s.nav}>
|
||||||
@@ -32,15 +51,23 @@ export default function App() {
|
|||||||
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
|
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
|
||||||
<div style={s.logoText}>CPAS Tracker</div>
|
<div style={s.logoText}>CPAS Tracker</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tabs.map(t => (
|
{tabs.map(t => (
|
||||||
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
|
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<button style={s.docsBtn} onClick={() => setShowReadme(true)} title="Open admin documentation">
|
||||||
|
<span>?</span> Docs
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={s.card}>
|
<div style={s.card}>
|
||||||
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
451
client/src/components/ReadmeModal.jsx
Normal file
451
client/src/components/ReadmeModal.jsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// ─── Minimal Markdown → HTML renderer ────────────────────────────────────────
|
||||||
|
// Handles: headings, bold, inline-code, fenced code blocks, tables, hr,
|
||||||
|
// unordered lists, ordered lists, and paragraphs.
|
||||||
|
function mdToHtml(md) {
|
||||||
|
const lines = md.split('\n');
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
let inUl = false;
|
||||||
|
let inOl = false;
|
||||||
|
let inTable = false;
|
||||||
|
let tableHead = false;
|
||||||
|
|
||||||
|
const closeOpenLists = () => {
|
||||||
|
if (inUl) { out.push('</ul>'); inUl = false; }
|
||||||
|
if (inOl) { out.push('</ol>'); inOl = false; }
|
||||||
|
if (inTable) { out.push('</tbody></table>'); inTable = false; tableHead = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const inline = (s) =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Fenced code block
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
closeOpenLists();
|
||||||
|
const lang = line.slice(3).trim();
|
||||||
|
const codeLines = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||||
|
codeLines.push(lines[i].replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<pre><code class="lang-${lang}">${codeLines.join('\n')}</code></pre>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HR
|
||||||
|
if (/^---+$/.test(line.trim())) {
|
||||||
|
closeOpenLists();
|
||||||
|
out.push('<hr>');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
const hMatch = line.match(/^(#{1,4})\s+(.+)/);
|
||||||
|
if (hMatch) {
|
||||||
|
closeOpenLists();
|
||||||
|
const level = hMatch[1].length;
|
||||||
|
const id = hMatch[2].toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||||
|
out.push(`<h${level} id="${id}">${inline(hMatch[2])}</h${level}>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table row
|
||||||
|
if (line.trim().startsWith('|')) {
|
||||||
|
const cells = line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
||||||
|
if (!inTable) {
|
||||||
|
closeOpenLists();
|
||||||
|
inTable = true;
|
||||||
|
tableHead = true;
|
||||||
|
out.push('<table><thead><tr>');
|
||||||
|
cells.forEach(c => out.push(`<th>${inline(c)}</th>`));
|
||||||
|
out.push('</tr></thead><tbody>');
|
||||||
|
i++;
|
||||||
|
// skip separator row
|
||||||
|
if (i < lines.length && lines[i].trim().startsWith('|') && /^[\|\s\-:]+$/.test(lines[i])) i++;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
out.push('<tr>');
|
||||||
|
cells.forEach(c => out.push(`<td>${inline(c)}</td>`));
|
||||||
|
out.push('</tr>');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
const ulMatch = line.match(/^[-*]\s+(.*)/);
|
||||||
|
if (ulMatch) {
|
||||||
|
if (inTable) closeOpenLists();
|
||||||
|
if (!inUl) { if (inOl) { out.push('</ol>'); inOl = false; } out.push('<ul>'); inUl = true; }
|
||||||
|
out.push(`<li>${inline(ulMatch[1])}</li>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
const olMatch = line.match(/^\d+\.\s+(.*)/);
|
||||||
|
if (olMatch) {
|
||||||
|
if (inTable) closeOpenLists();
|
||||||
|
if (!inOl) { if (inUl) { out.push('</ul>'); inUl = false; } out.push('<ol>'); inOl = true; }
|
||||||
|
out.push(`<li>${inline(olMatch[1])}</li>`);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank line
|
||||||
|
if (line.trim() === '') {
|
||||||
|
closeOpenLists();
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph
|
||||||
|
closeOpenLists();
|
||||||
|
out.push(`<p>${inline(line)}</p>`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOpenLists();
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||||
|
zIndex: 2000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
|
||||||
|
};
|
||||||
|
|
||||||
|
const panel = {
|
||||||
|
background: '#111217', color: '#f8f9fa', width: '760px', maxWidth: '95vw',
|
||||||
|
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 32px rgba(0,0,0,0.8)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBtn = {
|
||||||
|
background: 'none', border: 'none', color: 'white',
|
||||||
|
fontSize: '22px', cursor: 'pointer', lineHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
padding: '28px 32px', flex: 1, fontSize: '13px', lineHeight: '1.7',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Injected <style> for rendered markdown elements
|
||||||
|
const CSS = `
|
||||||
|
.readme-body h1 { font-size: 22px; font-weight: 800; color: #f8f9fa; margin: 28px 0 10px; border-bottom: 1px solid #2a2b3a; padding-bottom: 8px; }
|
||||||
|
.readme-body h2 { font-size: 17px; font-weight: 700; color: #d4af37; margin: 26px 0 8px; }
|
||||||
|
.readme-body h3 { font-size: 14px; font-weight: 700; color: #90caf9; margin: 20px 0 6px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||||
|
.readme-body h4 { font-size: 13px; font-weight: 700; color: #9ca0b8; margin: 14px 0 4px; }
|
||||||
|
.readme-body p { color: #c8ccd8; margin: 6px 0 10px; }
|
||||||
|
.readme-body hr { border: none; border-top: 1px solid #2a2b3a; margin: 20px 0; }
|
||||||
|
.readme-body strong { color: #f8f9fa; }
|
||||||
|
.readme-body code {
|
||||||
|
background: #0d1117; color: #79c0ff; border: 1px solid #2a2b3a;
|
||||||
|
border-radius: 4px; padding: 1px 6px; font-family: 'Consolas', 'Fira Code', monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.readme-body pre {
|
||||||
|
background: #0d1117; border: 1px solid #2a2b3a; border-radius: 6px;
|
||||||
|
padding: 14px 16px; overflow-x: auto; margin: 10px 0 16px;
|
||||||
|
}
|
||||||
|
.readme-body pre code {
|
||||||
|
background: none; border: none; padding: 0; color: #e6edf3;
|
||||||
|
font-size: 12px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.readme-body ul { padding-left: 22px; margin: 6px 0 10px; color: #c8ccd8; }
|
||||||
|
.readme-body ol { padding-left: 22px; margin: 6px 0 10px; color: #c8ccd8; }
|
||||||
|
.readme-body li { margin: 3px 0; }
|
||||||
|
.readme-body table {
|
||||||
|
width: 100%; border-collapse: collapse; font-size: 12px;
|
||||||
|
background: #181924; border-radius: 6px; overflow: hidden;
|
||||||
|
border: 1px solid #2a2b3a; margin: 10px 0 16px;
|
||||||
|
}
|
||||||
|
.readme-body 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;
|
||||||
|
}
|
||||||
|
.readme-body td {
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #202231; color: #c8ccd8;
|
||||||
|
}
|
||||||
|
.readme-body tr:last-child td { border-bottom: none; }
|
||||||
|
.readme-body tr:hover td { background: #1e1f2e; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Table-of-contents builder ────────────────────────────────────────────────
|
||||||
|
function buildToc(md) {
|
||||||
|
const headings = [];
|
||||||
|
for (const line of md.split('\n')) {
|
||||||
|
const m = line.match(/^(#{1,3})\s+(.+)/);
|
||||||
|
if (m) {
|
||||||
|
headings.push({
|
||||||
|
level: m[1].length,
|
||||||
|
text: m[2],
|
||||||
|
id: m[2].toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── README content ───────────────────────────────────────────────────────────
|
||||||
|
// Embedded at build time — no extra fetch needed.
|
||||||
|
const README_MD = `# CPAS Violation Tracker
|
||||||
|
|
||||||
|
Single-container Dockerized web app for CPAS violation documentation and workforce standing management.
|
||||||
|
Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart (Local)
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 1. Build the image
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
|
||||||
|
# 2. Run it
|
||||||
|
docker run -d --name cpas-tracker \\
|
||||||
|
-p 3001:3001 \\
|
||||||
|
-v cpas-data:/data \\
|
||||||
|
cpas-tracker
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Open **http://localhost:3001**
|
||||||
|
|
||||||
|
## Update After Code Changes
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
docker stop cpas-tracker && docker rm cpas-tracker
|
||||||
|
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Company Dashboard
|
||||||
|
- Live employee table sorted by active CPAS points (highest risk first)
|
||||||
|
- Stat cards: total employees, elite standing, active points, at-risk count, highest score
|
||||||
|
- **At-risk badge** — flags employees within 2 points of the next tier escalation
|
||||||
|
- Search/filter by name, department, or supervisor
|
||||||
|
- **Audit Log** button — filterable, paginated view of all system write actions
|
||||||
|
|
||||||
|
### Violation Form
|
||||||
|
- Select existing employee or enter new by name
|
||||||
|
- **Employee intelligence** — shows current CPAS standing before submitting
|
||||||
|
- Violation type dropdown grouped by category with 90-day recurrence counts
|
||||||
|
- **Recidivist auto-escalation** — points slider auto-maximizes on repeat violations
|
||||||
|
- **Tier crossing warning** — previews tier impact before submission
|
||||||
|
- One-click PDF download after submission
|
||||||
|
|
||||||
|
### Employee Profile Modal
|
||||||
|
- Full violation history with resolution status and amendment count badges
|
||||||
|
- **Edit Employee** — update name, department, or supervisor inline
|
||||||
|
- **Merge Duplicate** — reassign all violations from a duplicate record
|
||||||
|
- **Amend** per active violation — edit non-scoring fields with full diff history
|
||||||
|
- Negate / restore individual violations (soft delete with resolution type + notes)
|
||||||
|
- Hard delete option for data entry errors
|
||||||
|
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") visible in the profile
|
||||||
|
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window with tier-drop projections
|
||||||
|
|
||||||
|
### Audit Log
|
||||||
|
- Append-only log of every write action
|
||||||
|
- Filterable by entity type and action; paginated with load-more
|
||||||
|
|
||||||
|
### Violation Amendment
|
||||||
|
- Edit submitted violations' non-scoring fields without delete-and-resubmit
|
||||||
|
- Point values, violation type, and incident date are immutable
|
||||||
|
- Every change stored as a field-level diff (old → new) with timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CPAS Tier System
|
||||||
|
|
||||||
|
| Points | Tier | Label |
|
||||||
|
|--------|------|-------|
|
||||||
|
| 0–4 | 0–1 | Elite Standing |
|
||||||
|
| 5–9 | 1 | Realignment |
|
||||||
|
| 10–14 | 2 | Administrative Lockdown |
|
||||||
|
| 15–19 | 3 | Verification |
|
||||||
|
| 20–24 | 4 | Risk Mitigation |
|
||||||
|
| 25–29 | 5 | Final Decision |
|
||||||
|
| 30+ | 6 | Separation |
|
||||||
|
|
||||||
|
Scores are computed over a **rolling 90-day window** (negated violations excluded).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | \`/api/health\` | Health check |
|
||||||
|
| GET | \`/api/employees\` | List all employees (includes notes) |
|
||||||
|
| POST | \`/api/employees\` | Create or upsert employee |
|
||||||
|
| PATCH | \`/api/employees/:id\` | Edit name, department, supervisor, or notes |
|
||||||
|
| POST | \`/api/employees/:id/merge\` | Merge duplicate employee |
|
||||||
|
| GET | \`/api/employees/:id/score\` | Active CPAS score |
|
||||||
|
| GET | \`/api/employees/:id/expiration\` | Active violation roll-off timeline |
|
||||||
|
| PATCH | \`/api/employees/:id/notes\` | Save employee notes only |
|
||||||
|
| GET | \`/api/dashboard\` | All employees with active points |
|
||||||
|
| POST | \`/api/violations\` | Log a new violation |
|
||||||
|
| GET | \`/api/violations/employee/:id\` | Violation history with resolutions + amendment counts |
|
||||||
|
| PATCH | \`/api/violations/:id/negate\` | Soft delete + resolution record |
|
||||||
|
| PATCH | \`/api/violations/:id/restore\` | Restore a negated violation |
|
||||||
|
| PATCH | \`/api/violations/:id/amend\` | Amend non-scoring fields |
|
||||||
|
| GET | \`/api/violations/:id/amendments\` | Amendment history |
|
||||||
|
| DELETE | \`/api/violations/:id\` | Hard delete |
|
||||||
|
| GET | \`/api/violations/:id/pdf\` | Download violation PDF |
|
||||||
|
| GET | \`/api/audit\` | Paginated audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
Six tables + one view:
|
||||||
|
|
||||||
|
- **\`employees\`** — id, name, department, supervisor, **notes**
|
||||||
|
- **\`violations\`** — full incident record including \`prior_active_points\` snapshot
|
||||||
|
- **\`violation_resolutions\`** — resolution type, details, resolved_by
|
||||||
|
- **\`violation_amendments\`** — field-level diff log per amendment
|
||||||
|
- **\`audit_log\`** — append-only record of every write action
|
||||||
|
- **\`active_cpas_scores\`** (view) — rolling 90-day score per employee
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Amendable Fields
|
||||||
|
|
||||||
|
The following violation fields can be edited after submission. Points, type, and incident date are **immutable**.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| \`incident_time\` | Time of day the incident occurred |
|
||||||
|
| \`location\` | Where the incident took place |
|
||||||
|
| \`details\` | Narrative description |
|
||||||
|
| \`submitted_by\` | Supervisor who submitted |
|
||||||
|
| \`witness_name\` | Witness on record |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Quick Reference
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Build
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
|
||||||
|
|
||||||
|
# Stop / remove
|
||||||
|
docker stop cpas-tracker && docker rm cpas-tracker
|
||||||
|
|
||||||
|
# Export for Unraid
|
||||||
|
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs -f cpas-tracker
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export default function ReadmeModal({ onClose }) {
|
||||||
|
const bodyRef = useRef(null);
|
||||||
|
const html = mdToHtml(README_MD);
|
||||||
|
const toc = buildToc(README_MD);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const scrollTo = (id) => {
|
||||||
|
const el = bodyRef.current?.querySelector(`#${id}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverlay = (e) => { if (e.target === e.currentTarget) onClose(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlay} onClick={handleOverlay}>
|
||||||
|
{/* Inject markdown CSS once */}
|
||||||
|
<style>{CSS}</style>
|
||||||
|
|
||||||
|
<div style={panel} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={header}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '17px', fontWeight: 800, letterSpacing: '0.3px' }}>
|
||||||
|
📋 CPAS Tracker — Documentation
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '3px' }}>
|
||||||
|
Admin reference · use Esc or click outside to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={closeBtn} onClick={onClose} aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOC strip */}
|
||||||
|
<div style={{
|
||||||
|
background: '#0d1117', borderBottom: '1px solid #1e1f2e',
|
||||||
|
padding: '10px 32px', display: 'flex', flexWrap: 'wrap', gap: '4px 16px',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}>
|
||||||
|
{toc.filter(h => h.level <= 2).map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
onClick={() => scrollTo(h.id)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: '2px 0',
|
||||||
|
color: h.level === 1 ? '#f8f9fa' : '#90caf9',
|
||||||
|
fontWeight: h.level === 1 ? 700 : 400,
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.level === 2 ? '↳ ' : ''}{h.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
style={body}
|
||||||
|
className="readme-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 32px', borderTop: '1px solid #1e1f2e',
|
||||||
|
fontSize: '11px', color: '#555770', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
CPAS Violation Tracker · internal admin use only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user