docs: rewrite ReadmeModal as admin usage guide — feature map, workflow, tier system, roadmap
This commit is contained in:
@@ -1,286 +1,145 @@
|
||||
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 lines = md.split('\n');
|
||||
const out = [];
|
||||
let i = 0, inUl = false, inOl = false, inTable = 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 close = () => {
|
||||
if (inUl) { out.push('</ul>'); inUl = false; }
|
||||
if (inOl) { out.push('</ol>'); inOl = false; }
|
||||
if (inTable) { out.push('</tbody></table>'); inTable = false; }
|
||||
};
|
||||
|
||||
const inline = (s) =>
|
||||
s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
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 = [];
|
||||
close();
|
||||
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;
|
||||
while (i < lines.length && !lines[i].startsWith('```')) i++;
|
||||
i++; continue;
|
||||
}
|
||||
if (/^---+$/.test(line.trim())) { close(); out.push('<hr>'); 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(`<h${lvl} id="${id}">${inline(hm[2])}</h${lvl}>`);
|
||||
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());
|
||||
const cells = line.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim());
|
||||
if (!inTable) {
|
||||
closeOpenLists();
|
||||
inTable = true;
|
||||
tableHead = true;
|
||||
close(); inTable = 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++;
|
||||
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;
|
||||
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;
|
||||
const ul = line.match(/^[-*]\s+(.*)/);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
const ol = line.match(/^\d+\.\s+(.*)/);
|
||||
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;
|
||||
}
|
||||
|
||||
// Blank line
|
||||
if (line.trim() === '') {
|
||||
closeOpenLists();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === '') { close(); i++; continue; }
|
||||
|
||||
// Paragraph
|
||||
closeOpenLists();
|
||||
close();
|
||||
out.push(`<p>${inline(line)}</p>`);
|
||||
i++;
|
||||
}
|
||||
|
||||
closeOpenLists();
|
||||
close();
|
||||
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 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' },
|
||||
};
|
||||
|
||||
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; }
|
||||
.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; }
|
||||
`;
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
// ─── Admin guide content ──────────────────────────────────────────────────────
|
||||
const GUIDE_MD = `# CPAS Tracker — Admin Guide
|
||||
|
||||
// ─── 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).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart (Local)
|
||||
## How CPAS Scoring Works
|
||||
|
||||
\`\`\`bash
|
||||
# 1. Build the image
|
||||
docker build -t cpas-tracker .
|
||||
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.
|
||||
|
||||
# 2. Run it
|
||||
docker run -d --name cpas-tracker \\
|
||||
-p 3001:3001 \\
|
||||
-v cpas-data:/data \\
|
||||
cpas-tracker
|
||||
\`\`\`
|
||||
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
|
||||
|
||||
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
|
||||
## Tier Reference
|
||||
|
||||
| Points | Tier | Label |
|
||||
|--------|------|-------|
|
||||
@@ -292,137 +151,214 @@ docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
|
||||
| 25–29 | 5 | Final Decision |
|
||||
| 30+ | 6 | Separation |
|
||||
|
||||
Scores are computed over a **rolling 90-day window** (negated violations excluded).
|
||||
The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
## Feature Map
|
||||
|
||||
| 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 |
|
||||
### Dashboard
|
||||
|
||||
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
|
||||
- **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)
|
||||
- **Click any name** — opens that employee's full profile modal
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
### Logging a Violation
|
||||
|
||||
Six tables + one view:
|
||||
Use the **+ New Violation** tab.
|
||||
|
||||
- **\`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
|
||||
1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
|
||||
2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
|
||||
3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
|
||||
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
|
||||
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
|
||||
6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
|
||||
7. Submit. A **PDF download link** appears immediately — download it for the employee's file.
|
||||
|
||||
---
|
||||
|
||||
## Amendable Fields
|
||||
### Employee Profile Modal
|
||||
|
||||
The following violation fields can be edited after submission. Points, type, and incident date are **immutable**.
|
||||
Click any name on the dashboard to open their profile.
|
||||
|
||||
| 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 |
|
||||
#### Overview section
|
||||
Shows current tier badge, active points, and 90-day violation count.
|
||||
|
||||
#### Notes & Flags
|
||||
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
|
||||
|
||||
#### Point Expiration Timeline
|
||||
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
|
||||
|
||||
#### Violation History
|
||||
Full record of all submissions — active, negated, and resolved.
|
||||
|
||||
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
|
||||
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
|
||||
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
|
||||
- **PDF** — download the formal violation document for any historical record.
|
||||
|
||||
#### Edit Employee
|
||||
Update name, department, or supervisor. Changes are logged to the audit trail.
|
||||
|
||||
#### Merge Duplicate
|
||||
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
|
||||
|
||||
---
|
||||
|
||||
## Docker Quick Reference
|
||||
### Audit Log
|
||||
|
||||
\`\`\`bash
|
||||
# Build
|
||||
docker build -t cpas-tracker .
|
||||
Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
|
||||
|
||||
# Run
|
||||
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
|
||||
- Filter by entity type: **employee** or **violation**
|
||||
- Filter by action: created, edited, merged, negated, restored, amended, deleted, notes updated
|
||||
- Paginated with load-more; most recent entries first
|
||||
|
||||
# Stop / remove
|
||||
docker stop cpas-tracker && docker rm cpas-tracker
|
||||
The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
|
||||
|
||||
# Export for Unraid
|
||||
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
|
||||
---
|
||||
|
||||
# View logs
|
||||
docker logs -f cpas-tracker
|
||||
\`\`\`
|
||||
### Violation Amendment
|
||||
|
||||
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
|
||||
|
||||
**Amendable fields:** incident time, location, details, submitted-by, witness name.
|
||||
|
||||
**Immutable fields:** violation type, incident date, point value.
|
||||
|
||||
Each amendment stores a before/after diff for every changed field. Amendment history is accessible from the violation card in the employee's history.
|
||||
|
||||
---
|
||||
|
||||
## Immutability Rules — Quick Reference
|
||||
|
||||
| Action | Allowed? | Notes |
|
||||
|--------|----------|-------|
|
||||
| Edit violation type | No | Immutable after submission |
|
||||
| Edit incident date | No | Immutable after submission |
|
||||
| Edit point value | No | Immutable after submission |
|
||||
| Edit location / details / witness | Yes | Via Amend |
|
||||
| Negate (void) a violation | Yes | Soft delete; reversible |
|
||||
| Hard delete a violation | Yes | Permanent; use sparingly |
|
||||
| Edit employee name / dept / supervisor | Yes | Logged to audit trail |
|
||||
| Merge duplicate employees | Yes | Irreversible |
|
||||
| Add / edit employee notes | Yes | Does not affect score |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### ✅ Shipped
|
||||
|
||||
- Container scaffold, violation form, employee intelligence
|
||||
- Recidivist auto-escalation, tier crossing warning
|
||||
- PDF generation with prior-points snapshot
|
||||
- Company dashboard, stat cards, at-risk badges
|
||||
- Employee profile modal — full history, negate/restore, hard delete
|
||||
- Employee edit and duplicate merge
|
||||
- Violation amendment with field-level diff log
|
||||
- Audit log — filterable, paginated, append-only
|
||||
- Employee notes and flags with quick-add HR tags
|
||||
- Point expiration timeline with tier-drop projections
|
||||
- In-app admin guide (this panel)
|
||||
|
||||
---
|
||||
|
||||
### 🔜 Near-term
|
||||
|
||||
These are well-scoped additions that fit the current architecture without major changes.
|
||||
|
||||
- **Acknowledgment signature field** — "received by employee" name + date on the violation form; prints on the PDF in place of the blank signature line. Addresses the most common field workflow gap.
|
||||
- **CSV export** — one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
|
||||
- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
|
||||
|
||||
---
|
||||
|
||||
### 📋 Planned
|
||||
|
||||
Larger features that require more design work or infrastructure.
|
||||
|
||||
- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
|
||||
- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
|
||||
- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
|
||||
- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
|
||||
|
||||
---
|
||||
|
||||
### 🔭 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.
|
||||
- **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);
|
||||
const html = mdToHtml(README_MD);
|
||||
const toc = buildToc(README_MD);
|
||||
const bodyRef = useRef(null);
|
||||
const html = mdToHtml(GUIDE_MD);
|
||||
const toc = buildToc(GUIDE_MD);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
const h = e => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const scrollTo = (id) => {
|
||||
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 */}
|
||||
<div style={S.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<style>{CSS}</style>
|
||||
|
||||
<div style={panel} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={S.panel} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={header}>
|
||||
<div style={S.header}>
|
||||
<div>
|
||||
<div style={{ fontSize: '17px', fontWeight: 800, letterSpacing: '0.3px' }}>
|
||||
📋 CPAS Tracker — Documentation
|
||||
<div style={{ fontSize:'17px', fontWeight:800, letterSpacing:'.3px' }}>
|
||||
📋 CPAS Tracker — Admin Guide
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '3px' }}>
|
||||
Admin reference · use Esc or click outside to close
|
||||
<div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}>
|
||||
Feature map · workflow reference · roadmap · Esc or click outside to close
|
||||
</div>
|
||||
</div>
|
||||
<button style={closeBtn} onClick={onClose} aria-label="Close">✕</button>
|
||||
<button style={S.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) => (
|
||||
<div style={S.toc}>
|
||||
{toc.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',
|
||||
background:'none', border:'none', cursor:'pointer', padding:'2px 0',
|
||||
color: h.level === 1 ? '#f8f9fa' : '#d4af37',
|
||||
fontWeight: h.level === 1 ? 700 : 500,
|
||||
fontSize:'11px',
|
||||
}}
|
||||
>
|
||||
{h.level === 2 ? '↳ ' : ''}{h.text}
|
||||
@@ -433,16 +369,13 @@ export default function ReadmeModal({ onClose }) {
|
||||
{/* Body */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
style={body}
|
||||
className="readme-body"
|
||||
style={S.body}
|
||||
className="adm"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '14px 32px', borderTop: '1px solid #1e1f2e',
|
||||
fontSize: '11px', color: '#555770', textAlign: 'center',
|
||||
}}>
|
||||
<div style={S.footer}>
|
||||
CPAS Violation Tracker · internal admin use only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user