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