From f006c015b064501f26cdd78fc1b5ed32333837d2 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 14:14:11 -0600 Subject: [PATCH 01/81] Add client/public --- client/public | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 client/public diff --git a/client/public b/client/public new file mode 100644 index 0000000..e69de29 -- 2.52.0 From 6305cedb14b1e164dcfda0aced54c693c9c7dfa4 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 14:14:27 -0600 Subject: [PATCH 02/81] Delete client/public --- client/public | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 client/public diff --git a/client/public b/client/public deleted file mode 100644 index e69de29..0000000 -- 2.52.0 From eb07832cc7a05fc8568121a6d6dc48232e23ae2c Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 14:15:05 -0600 Subject: [PATCH 03/81] Upload files to "client/public/static" --- client/public/static/mpm-logo.png | Bin 0 -> 4188 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 client/public/static/mpm-logo.png diff --git a/client/public/static/mpm-logo.png b/client/public/static/mpm-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..02e50cc2a2713bdd2a2ade7301d92179d5b25707 GIT binary patch literal 4188 zcmV-i5ToyjP)vA9j;hK@3kU^t-860O0i+*t5g#rg|mW%<1)rA&!Lg5dx; zMLGHL_^CPr=Q&shi${J7!DMZD7ag!fhsQw8k!3TGXFqG#!8*3?0-0a6p-f5vnUqKv zJRs-CHeT_r+n_lP)=?NJ7$cDgOj2MjR32*f4MzUKZIivpV3oh$NeXx_C6a=X%AFkD z(Cv_Yv@!-OJy4(#>P$-f2}izbE6>}%?ZK}BJq>%tV1=<~4tV#g0k@qi=e57?7Br}v`49NU&=gIu+0cjeREe#?q2 zo$@5)DMwCqUC@^6xTV^i+pF+59Inyk(ALo<@XSg9cXYY%!oJ7BFxiO=bQrh?$cCNx ziX>|*+OsmN!@%9>jTmO4t{A{f;Rs}wf=Jr180dICZ?Z#X>3xwVEfr%}oodd0tbFPo z=RtizejjemB!<;D4)4h_7}n?n;0KmL_}mMISrQ}wzbzhu!ne|e%)-J>UnD%cT0GN% z5TMVwfHEI>Cuf#Owm=w=gp~|+(P>g(APYGV7%OsNmnU#pBZx%Z9CWVPxkMfbg1nP6 ztJ#s3qYhCs3&SKovm|;!%jPSg^bC2V3RHvC*szMB+9dks45S2P7WLyMk!%4lAmMpM z6H))QegM7`h!7Yc7Z^AjLWrx`yTC_3Ngxx+rF*kyF{};(`J~q-hzp)s$!CMWL+HDN zw4w@q84SH!&C?-_m7ofg?kQk{DqxcAh73?SPhshvEQ2Bk8+8dpeaI|Yq-f-6k=)D* z1IRlR7_^sS0pG2NWHV+!LN!Pc0lX&#hRDrEEiO^u| zDFbxglLEtla}!fYEK+fS`mAM8N(mWh5^Bq~-B#EgD5O7r;5`e{5#FW+K|U%hsBPJF z8}^>SwRK!r%OIxY?k#`3%rN8@Xsgq(w%U9Iy8CQe#-45^H1E0RpGdyq6@&uX1f6tPz?uIR2Y@Wvc zEpAroi|1bopU=gs@3G4)E8#8eI93IQDg??l{PGghfl^p;YGbzy;<9_7tZ;|PU~PP} zXY7_h>^Hn;LAT>Hy;Q6aHhWhXF>7PF3?j#+%kO%EJ6LERM}YzZzI+rkk+rgqskG}b zu)C9-bK&s63ypYAl$Smv7+dkeYxfBaeAID@53iB??!Xt~Qw5$`@JRwpGf9wZ#(cEd zRC)}0d8d}R*ZY+Ya)l?npsDpp?_exVv@!>VozN5WP40avJEJlyBY|^p&gkCx> zN{a6MpN?L0(SgjOzZhn_^IiH(`%r4Ua34RieE=nv_4B?Xb#Tw^GOjlTm!qo_hh2wN zU~qz?+Q6zTbcBMdgNHiZ`%d3N!R6?pAFA+5cTRLfn|`B9Amzcxr$-y#x^5?E>FqvP z4YNZJ%}h%-7K>hFGYgrp#M!{3{aSU^(Ql)!pKO@lf4kk*>Ac^V5-g4V;EeA(`S!aO z$!C^g!oFs^ohyB>wx+70z|G!z-3uegdsd&i$C0cC6)FA4Zq=IQYhS+{C#5oLwk>29 z{XHv8HY5y#j(EkiVd2nSIvBc>!^@ZMJ)L;$I*?g7d8wislDS?gbw;(&`ouxFZq>*4 zbrpsfBJhpMc}hgOz!0$YAG;|ZlFPNgQ1@2Py#f!xqUnw|{3O-t%uxRjj$b@Z_anay zBK7v=!0jA$?XqTGzfw1S>-GMl*N6HirmVhok0ZYfBKdi(+a43lWvs5|WKXWG$m+&f z1|i>cM^@=&wGR&9*NtBLb>-Wu{qH|}L2ohvy!6$*v(CA6b*5GJ&y;UU3z?Puz;flG zc@%0IL76FRdHb(I`DSTc%gqip@!qq|ph@YC_SuM?MD z2QrJkc6tSot$Y3MTsBQ;JhxWO>1|mG{rh%)*HPmIW83`h1N#|f78C#2gss0h$*j%q z-mmq2m9TC0ndWwmW*ZCdUha0^di}D2PtHbtsvri{#;T9@RLRME){!q(Zng%ldt7k& zQ|&Nqd_~IqV~0zJ`0D98w&`sunsg6h+b)w%(Y~p`r%8a~b{)PVfpml~`UFJ)&}c_r z41mskZmHCX^S#+up3>19H14iej4;H}z3XSD@j1rI200)~PBIH;&Y`lEtZ!q{HYiD@ z9<5W{QCj7nVq@1;7?c(Y3_DCX=UY}zE#`Q-aeOf>1suy@Y*xmysCeu>`hwkpx4z__fUTIBW|jxFeq)#mO)JaO+{0NH|>W16|^-Ke=p7& zR=>^~dRMJDy2nsp0JyHU(gPEt{WQ+u-S02q;0&3t36=w}JdGoWZA#>^enZZRSy##o zU&3G`#*BV*npqexx4US|m76Af(3<1d?vI0^Z0uM%=Q(|5;d=eW_W8q92PiGta)si@ zs%!UWy+=K^W9gjdtQu6E+qUM>rRtc{-u%Ojtg5ih*_}S4_))q=Gq1wQuTFE)LT15R zGlU@tVj8L47Z=ZtqjP@4nEzbN@#loi!BI9&cz)L)vv7h^MY-g*nf_M2aof*0NUd78 z{Z!aK?DRhu%q=r!k@hPLHYYsqYmvyT6BWCk=k$8W_*LEfW~23fr;CR_!tt~0m8+!{ zM;&vpl#Rlmw2)cKy!p4iZv4~ZaqcB#7QC|L=o1WS@%zcf7k|Ma^1Mz19Kc6C6CZDS=^51#O51njvojQiSKF=ceO)NHN_XiiPu95M zDjiPihUs;^#VmuD^qN@4AvH=iungwDbeAxlP?pag_B4l_>u-Jj8))w(9NglfY2$*% zRB?FWy4{1!@-7!ex2*b^eSASl0^6;-X!0j%$cCT=ZvmSTE#0_{LsN0^;mcUp3 zXzC_ZM613Vru+3sWfsmTYj-HeXzL~v9ILJyp1N+;r5-B`N^2=FU^xVjup*@&_+Blj z8SP%!A1r@(1_yk^#C2!5;d1cSE&2KDZg}cC2UpoB3`z@MkwDr(2`wn1JaStPUwd~h zo63PrrBXFceHb-qJ#>(cxW8=m7$>}pYjoH~3l9!5d|31%msvQ|8hb=LLR-7g5E`}Z zI8Nt{+VmqQJilv^duHJpzU;MI=Fz(^#I2LpW&CE&HfOfq#lcxRYxoX~!%sNa%Ek}d z={{70$~-#lsS}6p3L={CPFSM#pTq6Gpei-~6?t~dAkHDPqVaK)gtgf!+|C7!Va2l_ zb7Mq1JC@FQBr^*;v$pVox!6+W_9^eD3yQ9n-T5rl7WEgrbS*wQ@Gx=$nFS>~geM7N z+6x_DUw*!NEZ0veFaKokK@Z>0vDb-B>2UCsjl!U`kXbkpsiItRTU~!V{M#n4I*VtC zR=TmF9_n)i?P(I(%)%MB=;fhnxKw!Bx!j!pVt>ak88Mb7;)0=jxe1=%$_BuZe9wZo z1xj^QWq*hx{3czA%!isnnPw7evTL8aB7 z?y*T+x<0IWkuEUcnVK#%=D&3Fb`xU!Z2$G#0I7WGwtkp?_jUa!J;Z~rNT5+ybXzbn z+I9V$)~l&LUSQL{)WVdn0~q#)xSmHaOBL$8Ri3_l9x^dq|?hU3u}W?=Q~4 zMQp0L!~5T%`T~7;W<~!K3<=esk_E4h)%qQRerz2*^#73xlhyh;@VQU+33#zu24QlE ziX(eQLEOeWVi^fcAd?`-zQG?97$z>`X*=hyHy~HB3qE$sATA$^Ui;B{6Q&WvTudUs z*Cp!XM~GNvR{4RQ*O*bIYwe|%^17LYdno~#g`-RpVYo?HgiM7S!tB4?SZ(TC*>ht+ zJB2}MV^?6nIHxP_xzCM!3p-7fI$76RVVJa3zjl49Z?uQ^EJ$alGz3M^(nKh}Ty(2W zR^HAE!3B@hf54C+EdR;~?FoYZVw+hwGgZHJ+gX1eEYU6~(e>-3$BBXU%$oW1U}+-A zSP(*uAqbyV461*tz7CpQ`SW_hN zLr%#b|Mnet85Z@KHS~_@&m|m^ISg%nV^*Owlo-Xl{X9ZSh{RrqfH|1>V0~5z z*ct|F*qEnYmXKbFK!!z#2Ng*PYvetD8%NJNgrjep0%!Whv&MriGB39)qkW}mspJM!-&u7icjKY3_* z3I)b2v@$iOzKr=no^QM7nY)p9H}M=SWi$l_4Mm(TOJ(v*Bmd1YJ7>scetz4-$SaD_ z*t7;q;Y@j`D24LVhl^4uaQ*E>1c?LW4F!3;_ty(ZKMwvC-Jf1w00005Nkl Date: Fri, 6 Mar 2026 17:59:48 -0600 Subject: [PATCH 04/81] Upload files to "/" --- server.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/server.js b/server.js index 667afec..f442834 100755 --- a/server.js +++ b/server.js @@ -70,7 +70,7 @@ app.get('/api/violations/employee/:id', (req, res) => { res.json(rows); }); -// NEW helper: compute prior_active_points at time of insert (excluding this violation) +// Helper: compute prior_active_points at time of insert function getPriorActivePoints(employeeId, incidentDate) { const row = db.prepare( `SELECT COALESCE(SUM(points),0) AS pts @@ -116,9 +116,63 @@ app.post('/api/violations', (req, res) => { res.status(201).json({ id: result.lastInsertRowid }); }); -// Negate / restore / delete endpoints unchanged ... +// ── Negate a violation ────────────────────────────────────────────────────── +app.patch('/api/violations/:id/negate', (req, res) => { + const { resolution_type, details, resolved_by } = req.body; + const id = req.params.id; -// PDF endpoint — use stored prior_active_points snapshot + const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + + // Mark negated + db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id); + + // Upsert resolution record + const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id); + if (existing) { + db.prepare(` + UPDATE violation_resolutions + SET resolution_type = ?, details = ?, resolved_by = ?, created_at = datetime('now') + WHERE violation_id = ? + `).run(resolution_type || 'Resolved', details || null, resolved_by || null, id); + } else { + db.prepare(` + INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by) + VALUES (?, ?, ?, ?) + `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null); + } + + res.json({ success: true }); +}); + +// ── Restore a negated violation ───────────────────────────────────────────── +app.patch('/api/violations/:id/restore', (req, res) => { + const id = req.params.id; + + const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + + db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id); + db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); + + res.json({ success: true }); +}); + +// ── Hard delete a violation ───────────────────────────────────────────────── +app.delete('/api/violations/:id', (req, res) => { + const id = req.params.id; + + const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + + // Delete resolution first (FK safety) + db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); + db.prepare('DELETE FROM violations WHERE id = ?').run(id); + + res.json({ success: true }); +}); + +// ── PDF endpoint ───────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(` @@ -130,14 +184,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => { if (!violation) return res.status(404).json({ error: 'Violation not found' }); - // For PDF, compute score row but pass stored prior_active_points so math is stable const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?') .get(violation.employee_id) || { active_points: 0, violation_count: 0 }; const scoreForPdf = { - employee_id: violation.employee_id, - // snapshot at time of violation (if present); fall back to current - active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points, + employee_id: violation.employee_id, + active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points, violation_count: active.violation_count, }; -- 2.52.0 From a6447970accac67c62da054aee0bd8a209bcf161 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 23:18:28 -0600 Subject: [PATCH 05/81] docs: update README to reflect current codebase (phases 1-4 complete) --- README.md | 126 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a448dde..ad78091 100755 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # CPAS Violation Tracker -Single-container Dockerized web app for CPAS violation documentation. -Built with React + Vite (frontend), Node.js + Express (backend), SQLite (database). +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). + +--- ## The only requirement on your machine: Docker Desktop -Everything else — Node.js, npm, React build — happens inside Docker. +Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker. --- @@ -41,18 +43,88 @@ 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 table of all employees sorted by active CPAS points (highest risk first) +- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score +- **At-risk badge**: flags employees within 2 points of the next tier escalation +- Search/filter by name, department, or supervisor +- Click any employee name to open their full profile modal + +### Violation Form +- Select existing employee or enter new employee by name +- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting +- Violation type dropdown grouped by category; shows prior 90-day counts inline +- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy +- Repeat offense badge with prior count displayed +- Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type +- **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission +- Point slider for discretionary adjustments within the violation's min/max range +- One-click PDF download immediately after submission + +### Employee Profile Modal +- Full violation history with resolution status +- Negate / restore individual violations (soft delete with resolution type + notes) +- Hard delete option for data entry errors +- PDF download for any historical violation record + +### 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). + +### PDF Generation +- Puppeteer + system Chromium (bundled in Docker image) +- Generated on-demand per violation via `GET /api/violations/:id/pdf` +- Filename: `CPAS__.pdf` +- PDF captures prior active points **at the time of the incident** (snapshot stored on insert) + +--- + +## API Reference + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check | +| GET | `/api/employees` | List all employees | +| POST | `/api/employees` | Create or upsert employee | +| GET | `/api/employees/:id/score` | Get active CPAS score for employee | +| GET | `/api/dashboard` | All employees with active points + violation counts | +| POST | `/api/violations` | Log a new violation | +| GET | `/api/violations/employee/:id` | Get violation history for employee (with resolutions) | +| PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) | +| PATCH | `/api/violations/:id/restore` | Restore a negated violation | +| DELETE | `/api/violations/:id` | Hard delete a violation | +| GET | `/api/violations/:id/pdf` | Download violation PDF | + +--- + ## Project Structure ``` -cpas-violation-tracker/ -├── Dockerfile # Multi-stage: builds React + runs Express +cpas/ +├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium ├── .dockerignore -├── package.json # Backend (Express) deps -├── server.js # API + static file server +├── package.json # Backend (Express) deps +├── server.js # API + static file server ├── db/ -│ ├── schema.sql # Tables + 90-day score view -│ └── database.js # SQLite connection -└── client/ # React frontend (Vite) +│ ├── schema.sql # Tables + 90-day active score view +│ └── database.js # SQLite connection (better-sqlite3) +├── pdf/ +│ └── generator.js # Puppeteer PDF generation +└── client/ # React frontend (Vite) ├── package.json ├── vite.config.js ├── index.html @@ -60,14 +132,36 @@ cpas-violation-tracker/ ├── main.jsx ├── App.jsx ├── data/ - │ └── violations.js # All CPAS violation definitions + │ └── violations.js # All CPAS violation definitions + groups + ├── hooks/ + │ └── useEmployeeIntelligence.js # Score + history hook └── components/ - └── ViolationForm.jsx + ├── CpasBadge.jsx # Tier badge + color logic + ├── TierWarning.jsx # Pre-submit tier crossing alert + ├── Dashboard.jsx # Company-wide leaderboard + ├── ViolationForm.jsx # Violation entry form + ├── EmployeeModal.jsx # Employee profile + history modal + ├── NegateModal.jsx # Negate/resolve violation dialog + └── ViolationHistory.jsx # Violation list component ``` +--- + +## Database Schema + +Three tables + one view: + +- **`employees`** — id, name, department, supervisor +- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging +- **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations) +- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee + +--- + ## Phases + - [x] Phase 1 — Container scaffold, SQLite schema, base React form -- [ ] Phase 2 — Employee history, prior violation highlighting -- [ ] Phase 3 — Puppeteer PDF generation -- [ ] Phase 4 — Dashboard, CPAS scores, tier warnings -- [ ] Phase 5 — Recidivist point auto-suggest +- [x] Phase 2 — Employee history, prior violation highlighting, recidivist point auto-suggest +- [x] Phase 3 — Puppeteer PDF generation +- [x] Phase 4 — Dashboard, CPAS scores, tier warnings, at-risk badges +- [ ] Phase 5 — Recidivist point auto-suggest refinements / additional reporting -- 2.52.0 From 69272f71a32be995c2069d9ba7397058be1d4ccf Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 23:21:24 -0600 Subject: [PATCH 06/81] docs: add roadmap section with completed features and proposed enhancements --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ad78091..451eddd 100755 --- a/README.md +++ b/README.md @@ -158,10 +158,58 @@ Three tables + one view: --- -## Phases +## Roadmap -- [x] Phase 1 — Container scaffold, SQLite schema, base React form -- [x] Phase 2 — Employee history, prior violation highlighting, recidivist point auto-suggest -- [x] Phase 3 — Puppeteer PDF generation -- [x] Phase 4 — Dashboard, CPAS scores, tier warnings, at-risk badges -- [ ] Phase 5 — Recidivist point auto-suggest refinements / additional reporting +### ✅ Completed + +| Phase | Feature | Description | +|-------|---------|-------------| +| 1 | Container scaffold | Docker multi-stage build, Express server, SQLite schema | +| 1 | Base violation form | Employee fields, violation type, incident date, point submission | +| 2 | Employee intelligence | Live CPAS standing badge and 90-day count shown before submitting | +| 2 | Prior violation highlighting | Violation dropdown annotates types with 90-day recurrence counts | +| 2 | Recidivist auto-escalation | Points slider auto-maximizes on repeat same-type violations | +| 2 | Violation history | Per-employee history list with resolution status | +| 3 | PDF generation | Puppeteer/Chromium PDF per violation, downloadable immediately post-submit | +| 3 | Prior-points snapshot | `prior_active_points` captured at insert time for accurate historical PDFs | +| 4 | Company dashboard | Sortable employee table with live tier badges and at-risk flags | +| 4 | Stat cards | Summary counts: total, clean, active, at-risk, highest score | +| 4 | Tier crossing warning | Pre-submit alert when new points push employee to next tier | +| 4 | Employee profile modal | Full history, negate/restore, hard delete, per-record PDF download | +| 4 | Negate & restore | Soft-delete violations with resolution type + notes, fully reversible | + +--- + +### 🔲 Proposed + +#### Reporting & Analytics +- **Violation trends chart** — line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns vs. individual incidents +- **Department heat map** — grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk +- **Expiration timeline** — visual showing which active violations will roll off the 90-day window and when, so supervisors can anticipate tier drops +- **CSV / Excel export** — bulk export of violations or dashboard data for external reporting or payroll integration + +#### Employee Management +- **Employee edit / merge** — ability to update employee name, department, or supervisor without losing history; merge duplicate records created by name typos +- **Supervisor view** — scoped dashboard showing only the employees under a given supervisor, useful for multi-supervisor environments +- **Employee notes / flags** — free-text notes attached to an employee record (e.g. "on PIP", "union member") visible in the profile modal without affecting scoring + +#### Violation Workflow +- **Acknowledgment signature field** — a "received by employee" name/date field on the violation form that prints on the PDF, replacing the blank signature line +- **Draft / pending violations** — save a violation as draft before finalizing, useful when incidents need review before being officially logged +- **Violation amendment** — edit a submitted violation's details (not points) with an audit trail, rather than delete-and-resubmit +- **Bulk violation import** — CSV import for migrating historical records from paper logs or a prior system + +#### Notifications & Escalation +- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed +- **Scheduled summary digest** — weekly email to supervisors listing their employees' current standings and any approaching tier thresholds +- **At-risk threshold configuration** — make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment + +#### Infrastructure & Ops +- **Multi-user auth** — simple login with role-based access (admin, supervisor, read-only); currently the app has no auth and is assumed to run on a trusted internal network +- **Audit log** — immutable log of all creates, negates, restores, and deletes with timestamp and acting user, stored separately from the violations table +- **Automated DB backup** — cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule +- **Dark/light theme toggle** — the UI is currently dark-only; a toggle would improve usability in bright environments + +--- + +*Proposed features are suggestions based on common HR documentation workflows. Priority and implementation order should be driven by actual operational needs.* -- 2.52.0 From f4f191518cf72f7b5ecfb1dbf1d312875cbbb51d Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 23:26:39 -0600 Subject: [PATCH 07/81] fix: dark mode colors in ViolationHistory component --- client/src/components/ViolationHistory.jsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/ViolationHistory.jsx b/client/src/components/ViolationHistory.jsx index 711c22d..7b48107 100755 --- a/client/src/components/ViolationHistory.jsx +++ b/client/src/components/ViolationHistory.jsx @@ -2,19 +2,19 @@ import React, { useState } from 'react'; const s = { wrapper: { marginTop: '24px' }, - title: { color: '#2c3e50', fontSize: '16px', fontWeight: 700, marginBottom: '10px' }, - table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' }, - th: { background: '#2c3e50', color: 'white', padding: '8px 10px', textAlign: 'left' }, - td: { padding: '8px 10px', borderBottom: '1px solid #dee2e6' }, - trEven: { background: '#f8f9fa' }, - trOdd: { background: 'white' }, + title: { color: '#b5b5c0', fontSize: '16px', fontWeight: 700, marginBottom: '10px' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px', background: '#111217', borderRadius: '6px', overflow: 'hidden', border: '1px solid #222' }, + th: { background: '#000000', color: '#f8f9fa', padding: '8px 10px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }, + td: { padding: '8px 10px', borderBottom: '1px solid #1c1d29', color: '#f8f9fa', verticalAlign: 'middle' }, + trEven: { background: '#111217' }, + trOdd: { background: '#151622' }, pts: { fontWeight: 700, color: '#667eea' }, toggle: { background: 'none', border: 'none', color: '#667eea', cursor: 'pointer', fontSize: '13px', padding: 0, textDecoration: 'underline' }, - empty: { color: '#888', fontStyle: 'italic', fontSize: '13px', marginTop: '8px' }, + empty: { color: '#77798a', fontStyle: 'italic', fontSize: '13px', marginTop: '8px' }, }; function formatDate(d) { - if (!d) return '—'; + if (!d) return '–'; const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Chicago' }); } @@ -44,9 +44,9 @@ export default function ViolationHistory({ history, loading }) { {formatDate(v.incident_date)} {v.violation_name} - {v.category} + {v.category} {v.points} - {v.details || '—'} + {v.details || '–'} ))} -- 2.52.0 From 3977c3652f72a6946155b2be1ec9c045ab5d8f10 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 23:34:45 -0600 Subject: [PATCH 08/81] feat: redesign PDF template - clean modern layout with inline logo --- pdf/template.js | 627 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 504 insertions(+), 123 deletions(-) diff --git a/pdf/template.js b/pdf/template.js index 67fc5cc..c66fb6d 100755 --- a/pdf/template.js +++ b/pdf/template.js @@ -1,39 +1,49 @@ +// Inline base64 logo — avoids Puppeteer needing a live HTTP request to /static/ +const LOGO_B64 = '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'; + const TIERS = [ - { min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745' }, - { min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404' }, - { min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f' }, - { min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f' }, - { min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#c0392b' }, - { min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#c0392b' }, - { min: 30, max: 999,label: 'Tier 6 — Separation', color: '#721c24' }, + { min: 0, max: 4, label: 'Tier 0–1 — Elite Standing', color: '#16a34a', bg: '#f0fdf4' }, + { min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#854d0e', bg: '#fefce8' }, + { min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#b45309', bg: '#fff7ed' }, + { min: 15, max: 19, label: 'Tier 3 — Verification', color: '#c2410c', bg: '#fff7ed' }, + { min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' }, + { min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#991b1b', bg: '#fef2f2' }, + { min: 30, max: 999, label: 'Tier 6 — Separation', color: '#ffffff', bg: '#7f1d1d' }, ]; -function getTier(points) { return TIERS.find(t => points >= t.min && points <= t.max) || TIERS[0]; } - -function formatDate(d) { - if (!d) return '—'; - const dt = new Date(d + 'T12:00:00'); - return dt.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' }); +function getTier(pts) { + return TIERS.find(t => pts >= t.min && pts <= t.max) || TIERS[0]; } -function formatDateTime(d, t) { const date = formatDate(d); return t ? `${date} at ${t}` : date; } +function fmt(d) { + if (!d) return '—'; + return new Date(d + 'T12:00:00').toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'America/Chicago', + }); +} -function row(label, value) { +function fmtDT(d, t) { return t ? `${fmt(d)} at ${t}` : fmt(d); } + +function field(label, value) { + if (!value) return ''; return ` - - ${label} - ${value || '—'} - `; +
+
${label}
+
${value}
+
`; } function buildHtml(v, score) { - const priorPts = score.active_points || 0; // snapshot at time of logging - const priorTier= getTier(priorPts); - const newTotal = priorPts + v.points; // math always based on stored snapshot - const newTier = getTier(newTotal); - const tierChange = priorTier.label !== newTier.label; - - const generatedAt = new Date().toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'full', timeStyle: 'short' }); + const priorPts = score.active_points || 0; + const priorTier = getTier(priorPts); + const newTotal = priorPts + v.points; + const newTier = getTier(newTotal); + const escalated = priorTier.label !== newTier.label; + const genAt = new Date().toLocaleString('en-US', { + timeZone: 'America/Chicago', dateStyle: 'full', timeStyle: 'short', + }); + const docId = `CPAS-${v.id.toString().padStart(5, '0')}`; return ` @@ -41,128 +51,499 @@ function buildHtml(v, score) {
- +
-

CPAS Individual Violation Record

-

Message Point Media — Comprehensive Professional Accountability System

+
CPAS Violation Record
+
Comprehensive Professional Accountability System
-
Document ID: CPAS-${v.id.toString().padStart(5,'0')}
Generated: ${generatedAt}
+
+
${docId}
+
Generated ${genAt}
+
-
+
⚑ Confidential — Authorized HR & Management Use Only
-
⚠ CONFIDENTIAL — For authorized HR and management use only
+
-
-
Employee Information
- - ${row('Employee Name', `${v.employee_name}`)} - ${row('Department', v.department)} - ${row('Supervisor', v.supervisor)} - ${row('Witness / Documenting Officer', v.witness_name)} -
-
- -
-
Violation Details
- - ${row('Violation Type', `${v.violation_name}`)} - ${row('Category', v.category)} - ${row('Policy Reference', 'Chapter 4, Section 5 — Comprehensive Professional Accountability System (CPAS)')} - ${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))} - ${v.location ? row('Location / Context', v.location) : ''} - ${row('Submitted By', v.submitted_by || 'System')} -
- ${v.details ? `
Incident Details:
${v.details}
` : ''} -
- -
-
CPAS Point Assessment
-
${v.points}
Points Assessed — This Violation
-
-
-
${priorPts}
-
Active Points (Prior)
-
${priorTier.label}
+ +
+
+
Employee Information
+
-
+
-
-
${v.points}
-
Points — This Violation
-
-
=
-
-
${newTotal}
-
New Active Total
-
${newTier.label}
+
+
+
Employee Name
+
${v.employee_name}
+
+
+
Department
+
${v.department || '—'}
+
+
+
Supervisor
+
${v.supervisor || '—'}
+
+
+
Witness / Documenting Officer
+
${v.witness_name || '—'}
+
- ${tierChange ? `
⚠ Tier Escalation: This violation advances the employee from ${priorTier.label} to ${newTier.label}.
` : ''} -
-
-
CPAS Tier Reference
- - - ${TIERS.map(t => ``).join('')} -
PointsTier
${t.min === 30 ? '30+' : t.min + '–' + t.max}${t.label}
-
+ +
+
+
Violation Details
+
+
+
+
+
Violation
+
${v.violation_name}
+
+
+
Category
+
${v.category}
+
+
+
Incident Date / Time
+
${fmtDT(v.incident_date, v.incident_time)}
+
+
+
Submitted By
+
${v.submitted_by || 'System'}
+
+ ${v.location ? ` +
+
Location / Context
+
${v.location}
+
` : ''} +
+ ${v.details ? ` +
+
Incident Notes
+ ${v.details} +
` : ''} +
-
Employee Notice: CPAS points remain active for a rolling 90-day period from the date of each incident. Accumulation of points may result in tier escalation and associated consequences as outlined in the Employee Handbook.
+ +
+
+
CPAS Point Assessment
+
+
-
-
Acknowledgement & Signatures
-
-

By signing below, the employee acknowledges receipt of this violation record. Acknowledgement does not imply agreement. The employee may submit a written response within 5 business days.

+
+
${v.points}
+
+ Points Assessed + This violation +
+
+ +
+
+
${priorPts}
+
Prior Active Points
+ + ${priorTier.label} + +
+
+
+
+
${v.points}
+
This Violation
+
+
=
+
+
${newTotal}
+
New Active Total
+ + ${newTier.label} + +
+
+ + ${escalated ? ` +
+ +
+ Tier Escalation: + This violation advances the employee from ${priorTier.label} + to ${newTier.label}. +
+
` : ''} +
+ + +
+
+
CPAS Tier Reference
+
+
+ + + + + + + + + ${TIERS.map(t => { + const active = newTotal >= t.min && newTotal <= t.max; + const range = t.min === 30 ? '30+' : `${t.min}–${t.max}`; + return ` + + + `; + }).join('')} + +
PointsTier & Standing
${active ? '▶ ' : ''}${range} + + ${t.label} + ${active ? ' ← Current' : ''} +
+
+ + +
+ Employee Notice: CPAS points remain active for a rolling 90-day period from the date of each incident. + Accumulation of points may result in tier escalation and associated consequences as outlined in the Employee Handbook. + The employee may submit a written response within 5 business days of receiving this document. +
+ + +
+
+
Acknowledgement & Signatures
+
+
+

+ By signing below, the employee acknowledges receipt of this violation record. + Acknowledgement does not imply agreement with the violation as documented. +

-
-
Employee Signature
-
Date
+
+
+
Employee Signature
+ +
Date
-
-
Supervisor / Documenting Officer Signature
-
Date
+
+
+
Supervisor / Documenting Officer Signature
+ +
Date
-
- +
-- 2.52.0 From bcf0e3f3d14b912961755b083497ca4fa40f161e Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 23:40:49 -0600 Subject: [PATCH 09/81] fix: reduce PDF margins and remove redundant puppeteer footer --- pdf/generator.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/pdf/generator.js b/pdf/generator.js index afb37a6..22dcd42 100755 --- a/pdf/generator.js +++ b/pdf/generator.js @@ -30,18 +30,12 @@ async function generatePdf(violation, score) { format: 'Letter', printBackground: true, margin: { - top: '0.6in', - bottom: '0.7in', - left: '0.75in', - right: '0.75in', + top: '0.35in', + bottom: '0.35in', + left: '0.4in', + right: '0.4in', }, - displayHeaderFooter: true, - headerTemplate: '
', - footerTemplate: ` -
- CONFIDENTIAL — MPM Internal HR Document  |  - Page of -
`, + displayHeaderFooter: false, }); return pdf; -- 2.52.0 From 9b6f2353be7aee7c06a229bd73c6e1396faa9b07 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:22:01 -0600 Subject: [PATCH 10/81] feat(db): add violation_amendments and audit_log tables --- db/database.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/db/database.js b/db/database.js index a29416f..cba0200 100755 --- a/db/database.js +++ b/db/database.js @@ -13,12 +13,12 @@ db.pragma('foreign_keys = ON'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); db.exec(schema); -// ── Migrations for existing DBs ───────────────────────────────────────────── +// ── Migrations for existing DBs ────────────────────────────────────────────── const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name); -if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); -if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); -if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER"); -if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT"); +if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0"); +if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME"); +if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER"); +if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT"); // Ensure resolutions table exists db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( @@ -30,6 +30,30 @@ db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); +// ── Feature: Violation Amendments ──────────────────────────────────────────── +// Stores a field-level diff every time a violation's editable fields are changed. +db.exec(`CREATE TABLE IF NOT EXISTS violation_amendments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE, + changed_by TEXT, + field_name TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +)`); + +// ── Feature: Audit Log ─────────────────────────────────────────────────────── +// Append-only record of every write action across the system. +db.exec(`CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + performed_by TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +)`); + // Recreate view so it always filters negated rows db.exec(`DROP VIEW IF EXISTS active_cpas_scores; CREATE VIEW active_cpas_scores AS -- 2.52.0 From 5004c569152f9599ae0aaaaeacaeda1552a28520 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:23:04 -0600 Subject: [PATCH 11/81] feat: employee edit/merge, violation amendment, audit log endpoints --- server.js | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 10 deletions(-) diff --git a/server.js b/server.js index f442834..0a33626 100755 --- a/server.js +++ b/server.js @@ -11,10 +11,23 @@ app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client', 'dist'))); +// ── Audit helper ───────────────────────────────────────────────────────────── +function audit(action, entityType, entityId, performedBy, details) { + try { + db.prepare(` + INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details) + VALUES (?, ?, ?, ?, ?) + `).run(action, entityType, entityId || null, performedBy || null, + typeof details === 'object' ? JSON.stringify(details) : (details || null)); + } catch (e) { + console.error('[AUDIT]', e.message); + } +} + // Health app.get('/api/health', (req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); -// Employees +// ── Employees ───────────────────────────────────────────────────────────────── app.get('/api/employees', (req, res) => { const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all(); res.json(rows); @@ -33,9 +46,72 @@ app.post('/api/employees', (req, res) => { } const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)') .run(name, department || null, supervisor || null); + audit('employee_created', 'employee', result.lastInsertRowid, null, { name }); res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); +// ── Employee Edit ───────────────────────────────────────────────────────────── +// PATCH /api/employees/:id — update name, department, or supervisor +app.patch('/api/employees/:id', (req, res) => { + const id = parseInt(req.params.id); + const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id); + if (!emp) return res.status(404).json({ error: 'Employee not found' }); + + const { name, department, supervisor, performed_by } = req.body; + + // Prevent name collision with a different employee + if (name && name.trim() !== emp.name) { + const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id); + if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id }); + } + + const newName = (name || emp.name).trim(); + const newDept = department !== undefined ? (department || null) : emp.department; + const newSupervisor = supervisor !== undefined ? (supervisor || null) : emp.supervisor; + + db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ? WHERE id = ?') + .run(newName, newDept, newSupervisor, id); + + audit('employee_edited', 'employee', id, performed_by, { + before: { name: emp.name, department: emp.department, supervisor: emp.supervisor }, + after: { name: newName, department: newDept, supervisor: newSupervisor }, + }); + + res.json({ id, name: newName, department: newDept, supervisor: newSupervisor }); +}); + +// ── Employee Merge ──────────────────────────────────────────────────────────── +// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source +app.post('/api/employees/:id/merge', (req, res) => { + const targetId = parseInt(req.params.id); + const { source_id, performed_by } = req.body; + if (!source_id) return res.status(400).json({ error: 'source_id is required' }); + + const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId); + const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id); + if (!target) return res.status(404).json({ error: 'Target employee not found' }); + if (!source) return res.status(404).json({ error: 'Source employee not found' }); + if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' }); + + const mergeTransaction = db.transaction(() => { + // Move all violations + const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id); + // Delete the source employee + db.prepare('DELETE FROM employees WHERE id = ?').run(source_id); + return moved.changes; + }); + + const violationsMoved = mergeTransaction(); + + audit('employee_merged', 'employee', targetId, performed_by, { + source: { id: source.id, name: source.name }, + target: { id: target.id, name: target.name }, + violations_reassigned: violationsMoved, + }); + + res.json({ success: true, violations_reassigned: violationsMoved }); +}); + // Employee score (current snapshot) app.get('/api/employees/:id/score', (req, res) => { const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.id); @@ -55,12 +131,13 @@ app.get('/api/dashboard', (req, res) => { res.json(rows); }); -// Violation history (per employee) with resolutions +// Violation history (per employee) with resolutions + amendment count app.get('/api/violations/employee/:id', (req, res) => { const limit = parseInt(req.query.limit) || 50; const rows = db.prepare(` SELECT v.*, r.resolution_type, r.details AS resolution_details, - r.resolved_by, r.created_at AS resolved_at + r.resolved_by, r.created_at AS resolved_at, + (SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count FROM violations v LEFT JOIN violation_resolutions r ON r.violation_id = v.id WHERE v.employee_id = ? @@ -70,6 +147,14 @@ app.get('/api/violations/employee/:id', (req, res) => { res.json(rows); }); +// ── Violation amendment history ─────────────────────────────────────────────── +app.get('/api/violations/:id/amendments', (req, res) => { + const rows = db.prepare(` + SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC + `).all(req.params.id); + res.json(rows); +}); + // Helper: compute prior_active_points at time of insert function getPriorActivePoints(employeeId, incidentDate) { const row = db.prepare( @@ -113,10 +198,61 @@ app.post('/api/violations', (req, res) => { priorPts ); + audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, { + employee_id, violation_type, points: ptsInt, incident_date, + }); + res.status(201).json({ id: result.lastInsertRowid }); }); -// ── Negate a violation ────────────────────────────────────────────────────── +// ── Violation Amendment (edit) ──────────────────────────────────────────────── +// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field +const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name']; + +app.patch('/api/violations/:id/amend', (req, res) => { + const id = parseInt(req.params.id); + const { changed_by, ...updates } = req.body; + + const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' }); + + // Only allow whitelisted fields to be amended + const allowed = Object.fromEntries( + Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k)) + ); + if (Object.keys(allowed).length === 0) { + return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS }); + } + + const amendTransaction = db.transaction(() => { + // Build UPDATE + const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', '); + const values = [...Object.values(allowed), id]; + db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values); + + // Insert an amendment record per changed field + const insertAmendment = db.prepare(` + INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value) + VALUES (?, ?, ?, ?, ?) + `); + for (const [field, newVal] of Object.entries(allowed)) { + const oldVal = violation[field]; + if (String(oldVal) !== String(newVal)) { + insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null); + } + } + }); + + amendTransaction(); + + audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) }); + + const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); + res.json(updated); +}); + +// ── Negate a violation ──────────────────────────────────────────────────────── app.patch('/api/violations/:id/negate', (req, res) => { const { resolution_type, details, resolved_by } = req.body; const id = req.params.id; @@ -124,10 +260,8 @@ app.patch('/api/violations/:id/negate', (req, res) => { const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); - // Mark negated db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id); - // Upsert resolution record const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id); if (existing) { db.prepare(` @@ -142,10 +276,11 @@ app.patch('/api/violations/:id/negate', (req, res) => { `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null); } + audit('violation_negated', 'violation', id, resolved_by, { resolution_type }); res.json({ success: true }); }); -// ── Restore a negated violation ───────────────────────────────────────────── +// ── Restore a negated violation ─────────────────────────────────────────────── app.patch('/api/violations/:id/restore', (req, res) => { const id = req.params.id; @@ -155,24 +290,46 @@ app.patch('/api/violations/:id/restore', (req, res) => { db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id); db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); + audit('violation_restored', 'violation', id, req.body?.performed_by, {}); res.json({ success: true }); }); -// ── Hard delete a violation ───────────────────────────────────────────────── +// ── Hard delete a violation ─────────────────────────────────────────────────── app.delete('/api/violations/:id', (req, res) => { const id = req.params.id; const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id); if (!violation) return res.status(404).json({ error: 'Violation not found' }); - // Delete resolution first (FK safety) db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id); db.prepare('DELETE FROM violations WHERE id = ?').run(id); + audit('violation_deleted', 'violation', id, req.body?.performed_by, { + violation_type: violation.violation_type, employee_id: violation.employee_id, + }); res.json({ success: true }); }); -// ── PDF endpoint ───────────────────────────────────────────────────────────── +// ── Audit log ───────────────────────────────────────────────────────────────── +app.get('/api/audit', (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 100, 500); + const offset = parseInt(req.query.offset) || 0; + const type = req.query.entity_type; + const id = req.query.entity_id; + + let sql = 'SELECT * FROM audit_log'; + const args = []; + const where = []; + if (type) { where.push('entity_type = ?'); args.push(type); } + if (id) { where.push('entity_id = ?'); args.push(id); } + if (where.length) sql += ' WHERE ' + where.join(' AND '); + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + args.push(limit, offset); + + res.json(db.prepare(sql).all(...args)); +}); + +// ── PDF endpoint ────────────────────────────────────────────────────────────── app.get('/api/violations/:id/pdf', async (req, res) => { try { const violation = db.prepare(` -- 2.52.0 From ee91a16506ea0fc3ba166a41f5a2bc937e42f5c7 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:23:39 -0600 Subject: [PATCH 12/81] =?UTF-8?q?feat:=20EditEmployeeModal=20=E2=80=94=20e?= =?UTF-8?q?dit=20name/dept/supervisor=20and=20merge=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/EditEmployeeModal.jsx | 189 ++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 client/src/components/EditEmployeeModal.jsx diff --git a/client/src/components/EditEmployeeModal.jsx b/client/src/components/EditEmployeeModal.jsx new file mode 100644 index 0000000..a438e1b --- /dev/null +++ b/client/src/components/EditEmployeeModal.jsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; + +const s = { + overlay: { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', + zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', + }, + modal: { + background: '#111217', color: '#f8f9fa', width: '480px', maxWidth: '95vw', + borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)', + border: '1px solid #222', overflow: 'hidden', + }, + header: { + background: 'linear-gradient(135deg, #000000, #151622)', color: 'white', + padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', + borderBottom: '1px solid #222', + }, + title: { fontSize: '15px', fontWeight: 700 }, + closeBtn: { + background: 'none', border: 'none', color: 'white', fontSize: '20px', + cursor: 'pointer', lineHeight: 1, + }, + body: { padding: '22px' }, + tabs: { display: 'flex', gap: '4px', marginBottom: '20px' }, + tab: (active) => ({ + flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer', fontSize: '12px', + fontWeight: 700, textAlign: 'center', border: '1px solid', + background: active ? '#1a1c2e' : 'none', + borderColor: active ? '#667eea' : '#2a2b3a', + color: active ? '#667eea' : '#777', + }), + label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' }, + input: { + width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px', + outline: 'none', boxSizing: 'border-box', + }, + select: { + width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px', + outline: 'none', boxSizing: 'border-box', + }, + row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' }, + btn: (color, bg) => ({ + padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px', + cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none', + }), + error: { + background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px', + padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px', + }, + success: { + background: '#0a2e1f', border: '1px solid #0f5132', borderRadius: '6px', + padding: '10px 12px', fontSize: '12px', color: '#9ef7c1', marginBottom: '14px', + }, + mergeWarning: { + background: '#2a1f00', border: '1px solid #7a5000', borderRadius: '6px', + padding: '12px', fontSize: '12px', color: '#ffc107', marginBottom: '14px', lineHeight: 1.5, + }, +}; + +export default function EditEmployeeModal({ employee, onClose, onSaved }) { + const [tab, setTab] = useState('edit'); + + // Edit state + const [name, setName] = useState(employee.name); + const [department, setDepartment] = useState(employee.department || ''); + const [supervisor, setSupervisor] = useState(employee.supervisor || ''); + const [editError, setEditError] = useState(''); + const [editSaving, setEditSaving] = useState(false); + + // Merge state + const [allEmployees, setAllEmployees] = useState([]); + const [sourceId, setSourceId] = useState(''); + const [mergeError, setMergeError] = useState(''); + const [mergeResult, setMergeResult] = useState(null); + const [merging, setMerging] = useState(false); + + useEffect(() => { + if (tab === 'merge') { + axios.get('/api/employees').then(r => setAllEmployees(r.data)); + } + }, [tab]); + + const handleEdit = async () => { + setEditError(''); + setEditSaving(true); + try { + await axios.patch(`/api/employees/${employee.id}`, { name, department, supervisor }); + onSaved(); + onClose(); + } catch (e) { + setEditError(e.response?.data?.error || 'Failed to save changes'); + } finally { + setEditSaving(false); + } + }; + + const handleMerge = async () => { + if (!sourceId) return setMergeError('Select an employee to merge in'); + setMergeError(''); + setMerging(true); + try { + const r = await axios.post(`/api/employees/${employee.id}/merge`, { source_id: parseInt(sourceId) }); + setMergeResult(r.data); + onSaved(); // refresh dashboard / parent list + } catch (e) { + setMergeError(e.response?.data?.error || 'Merge failed'); + } finally { + setMerging(false); + } + }; + + const otherEmployees = allEmployees.filter(e => e.id !== employee.id); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
Edit Employee
+ +
+
+
+ + +
+ + {tab === 'edit' && ( + <> + {editError &&
{editError}
} +
Full Name
+ setName(e.target.value)} /> +
Department
+ setDepartment(e.target.value)} placeholder="Optional" /> +
Supervisor
+ setSupervisor(e.target.value)} placeholder="Optional" /> +
+ + +
+ + )} + + {tab === 'merge' && ( + <> + {mergeResult ? ( +
+ ✓ Merge complete — {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned + to {employee.name}. The duplicate record has been removed. +
+ ) : ( + <> +
+ ⚠ This will reassign all violations from the selected employee into{' '} + {employee.name}, then permanently delete the duplicate record. + This cannot be undone. +
+ {mergeError &&
{mergeError}
} +
Duplicate to merge into {employee.name}
+ +
+ + +
+ + )} + {mergeResult && ( +
+ +
+ )} + + )} +
+
+
+ ); +} -- 2.52.0 From 15d3f028840fa2532ad55b2ac4e12d9a73b76ec8 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 7 Mar 2026 09:24:13 -0600 Subject: [PATCH 13/81] =?UTF-8?q?feat:=20AmendViolationModal=20=E2=80=94?= =?UTF-8?q?=20edit=20non-scoring=20fields=20with=20full=20diff=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/AmendViolationModal.jsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 client/src/components/AmendViolationModal.jsx diff --git a/client/src/components/AmendViolationModal.jsx b/client/src/components/AmendViolationModal.jsx new file mode 100644 index 0000000..c9dbd91 --- /dev/null +++ b/client/src/components/AmendViolationModal.jsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; + +const FIELD_LABELS = { + incident_time: 'Incident Time', + location: 'Location / Context', + details: 'Incident Notes', + submitted_by: 'Submitted By', + witness_name: 'Witness / Documenting Officer', +}; + +const s = { + overlay: { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', + zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', + }, + modal: { + background: '#111217', color: '#f8f9fa', width: '520px', maxWidth: '95vw', + maxHeight: '90vh', overflowY: 'auto', + borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)', + border: '1px solid #222', + }, + header: { + background: 'linear-gradient(135deg, #000000, #151622)', color: 'white', + padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', + borderBottom: '1px solid #222', position: 'sticky', top: 0, zIndex: 10, + }, + headerLeft: {}, + title: { fontSize: '15px', fontWeight: 700 }, + subtitle: { fontSize: '11px', color: '#9ca0b8', marginTop: '2px' }, + closeBtn: { + background: 'none', border: 'none', color: 'white', fontSize: '20px', + cursor: 'pointer', lineHeight: 1, + }, + body: { padding: '22px' }, + notice: { + background: '#0e1a30', border: '1px solid #1e3a5f', borderRadius: '6px', + padding: '10px 14px', fontSize: '12px', color: '#7eb8f7', marginBottom: '18px', + }, + label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' }, + input: { + width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px', + outline: 'none', boxSizing: 'border-box', + }, + textarea: { + width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px', + color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px', + outline: 'none', boxSizing: 'border-box', minHeight: '80px', resize: 'vertical', + }, + divider: { borderTop: '1px solid #1c1d29', margin: '16px 0' }, + sectionTitle: { + fontSize: '11px', fontWeight: 700, color: '#9ca0b8', textTransform: 'uppercase', + letterSpacing: '0.5px', marginBottom: '12px', + }, + amendRow: { + background: '#0d0e14', border: '1px solid #1c1d29', borderRadius: '6px', + padding: '10px 12px', marginBottom: '8px', fontSize: '12px', + }, + amendField: { fontWeight: 700, color: '#c0c2d6', marginBottom: '4px' }, + amendOld: { color: '#ff7070', textDecoration: 'line-through', marginRight: '6px' }, + amendNew: { color: '#9ef7c1' }, + amendMeta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' }, + row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' }, + btn: (color, bg) => ({ + padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px', + cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none', + }), + error: { + background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px', + padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px', + }, +}; + +function fmtDt(iso) { + if (!iso) return '—'; + return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' }); +} + +export default function AmendViolationModal({ violation, onClose, onSaved }) { + const [fields, setFields] = useState({ + incident_time: violation.incident_time || '', + location: violation.location || '', + details: violation.details || '', + submitted_by: violation.submitted_by || '', + witness_name: violation.witness_name || '', + }); + const [changedBy, setChangedBy] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [amendments, setAmendments] = useState([]); + + useEffect(() => { + axios.get(`/api/violations/${violation.id}/amendments`) + .then(r => setAmendments(r.data)) + .catch(() => {}); + }, [violation.id]); + + const hasChanges = Object.entries(fields).some( + ([k, v]) => v !== (violation[k] || '') + ); + + const handleSave = async () => { + setError(''); + setSaving(true); + try { + // Only send fields that actually changed + const patch = Object.fromEntries( + Object.entries(fields).filter(([k, v]) => v !== (violation[k] || '')) + ); + await axios.patch(`/api/violations/${violation.id}/amend`, { ...patch, changed_by: changedBy || null }); + onSaved(); + onClose(); + } catch (e) { + setError(e.response?.data?.error || 'Failed to save amendment'); + } finally { + setSaving(false); + } + }; + + const set = (field, value) => setFields(prev => ({ ...prev, [field]: value })); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Amend Violation
+
+ CPAS-{String(violation.id).padStart(5, '0')} · {violation.violation_name} · {violation.incident_date} +
+
+ +
+ +
+
+ Only non-scoring fields can be amended. Point values, violation type, and incident date + are immutable — delete and re-submit if those need to change. +
+ + {error &&
{error}
} + + {Object.entries(FIELD_LABELS).map(([field, label]) => ( +
+
{label}
+ {field === 'details' ? ( +