From 62b142d4a376b221aae34d61220e9889355d93a1 Mon Sep 17 00:00:00 2001 From: Jason UNRAID Date: Fri, 6 Mar 2026 12:19:55 -0600 Subject: [PATCH 001/106] Phase 3 --- client/src/components/ViolationForm.jsx | 106 +++++---- package.json | 3 +- pdf/generator.js | 52 +++++ pdf/template.js | 272 ++++++++++++++++++++++++ server.js | 54 ++++- 5 files changed, 438 insertions(+), 49 deletions(-) create mode 100755 pdf/generator.js create mode 100755 pdf/template.js diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx index 03344f2..165515c 100755 --- a/client/src/components/ViolationForm.jsx +++ b/client/src/components/ViolationForm.jsx @@ -23,6 +23,7 @@ const s = { scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' }, btnRow: { display: 'flex', gap: '15px', justifyContent: 'center', marginTop: '30px', flexWrap: 'wrap' }, btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', textTransform: 'uppercase' }, + btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' }, btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: '#6c757d', color: 'white', textTransform: 'uppercase' }, note: { background: '#e7f3ff', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px' }, statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' }, @@ -36,24 +37,23 @@ const EMPTY_FORM = { }; export default function ViolationForm() { - const [employees, setEmployees] = useState([]); - const [form, setForm] = useState(EMPTY_FORM); - const [violation, setViolation] = useState(null); - const [status, setStatus] = useState(null); + const [employees, setEmployees] = useState([]); + const [form, setForm] = useState(EMPTY_FORM); + const [violation, setViolation] = useState(null); + const [status, setStatus] = useState(null); + const [lastViolId, setLastViolId] = useState(null); // ID of most recently saved violation + const [pdfLoading, setPdfLoading] = useState(false); - // Phase 2: pull score + history whenever employee changes const intel = useEmployeeIntelligence(form.employeeId || null); useEffect(() => { axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {}); }, []); - // When violation type changes, check all-time counts and auto-suggest higher pts for recidivists useEffect(() => { if (!violation || !form.violationType) return; const allTime = intel.countsAllTime[form.violationType]; if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) { - // Suggest max points for repeat offenders setForm(prev => ({ ...prev, points: violation.maxPoints })); } else { setForm(prev => ({ ...prev, points: violation.minPoints })); @@ -82,18 +82,26 @@ export default function ViolationForm() { try { const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); const employeeId = empRes.data.id; - await axios.post('/api/violations', { - employee_id: employeeId, violation_type: form.violationType, + const violRes = await axios.post('/api/violations', { + employee_id: employeeId, + violation_type: form.violationType, violation_name: violation?.name || form.violationType, - category: violation?.category || 'General', points: parseInt(form.points), - incident_date: form.incidentDate, incident_time: form.incidentTime || null, - location: form.location || null, details: form.additionalDetails || null, - witness_name: form.witnessName || null, + category: violation?.category || 'General', + points: parseInt(form.points), + incident_date: form.incidentDate, + incident_time: form.incidentTime || null, + location: form.location || null, + details: form.additionalDetails || null, + witness_name: form.witnessName || null, }); - // Refresh employee list and re-run intel for updated score + + const newId = violRes.data.id; + setLastViolId(newId); + const empList = await axios.get('/api/employees'); setEmployees(empList.data); - setStatus({ ok: true, msg: '✓ Violation recorded successfully' }); + + setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` }); setForm(EMPTY_FORM); setViolation(null); } catch (err) { @@ -101,6 +109,28 @@ export default function ViolationForm() { } }; + const handleDownloadPdf = async () => { + if (!lastViolId) return; + setPdfLoading(true); + try { + const response = await axios.get(`/api/violations/${lastViolId}/pdf`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.download = `CPAS_Violation_${lastViolId}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (err) { + setStatus({ ok: false, msg: '✗ PDF generation failed: ' + err.message }); + } finally { + setPdfLoading(false); + } + }; + const showField = f => violation?.fields?.includes(f); const priorCount90 = key => intel.counts90[key] || 0; const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1; @@ -108,11 +138,10 @@ export default function ViolationForm() { return (
- {/* ── Employee Information ────────────────────────────────── */} + {/* ── Employee Information ─────────────────────────────── */}

Employee Information

- {/* CPAS score banner — shown once employee is selected */} {intel.score && form.employeeId && (
Current Standing: @@ -145,13 +174,12 @@ export default function ViolationForm() {
- {/* ── Violation Details ────────────────────────────────────── */} + {/* ── Violation Details ────────────────────────────────── */}

Violation Details

- {/* Violation type dropdown with prior-use badges */}
- {/* Handbook definition */} {violation && (
{violation.name} @@ -179,13 +206,11 @@ export default function ViolationForm() { ★ Repeat — {intel.countsAllTime[form.violationType]?.count}x prior )} -
- {violation.description}
+
{violation.description}
{violation.chapter}
)} - {/* Recidivist auto-suggest notice */} {violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
Repeat offense detected. Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed. @@ -193,7 +218,6 @@ export default function ViolationForm() { )}
- {/* Incident date */}
@@ -231,7 +255,6 @@ export default function ViolationForm() { )}
- {/* Tier escalation warning */} {intel.score && violation && ( )} - {/* Point slider */} {violation && (

CPAS Point Assessment

@@ -248,33 +270,43 @@ export default function ViolationForm() { ? `${violation.minPoints} Points (Fixed)` : `${violation.minPoints}–${violation.maxPoints} Points`}

- + value={form.points} onChange={handleChange} />
{form.points} Points

Adjust to reflect severity and context

)}
-
- Note: Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3. -
-
-
+ {/* PDF download — appears after successful submission */} + {lastViolId && status?.ok && ( +
+ +

+ Violation #{lastViolId} — click to download the signed violation document +

+
+ )} + {status &&
{status.msg}
} - {/* ── Violation History Panel ──────────────────────────────── */} + {/* ── Violation History Panel ──────────────────────────── */} {form.employeeId && (

Violation History

diff --git a/package.json b/package.json index 8137023..22c607f 100755 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dependencies": { "better-sqlite3": "^9.4.3", "cors": "^2.8.5", - "express": "^4.18.3" + "express": "^4.18.3", + "puppeteer-core": "^22.0.0" }, "devDependencies": { "nodemon": "^3.1.0" diff --git a/pdf/generator.js b/pdf/generator.js new file mode 100755 index 0000000..c230aac --- /dev/null +++ b/pdf/generator.js @@ -0,0 +1,52 @@ +const puppeteer = require('puppeteer'); +const buildHtml = require('./template'); + +/** + * Renders the violation document HTML via Puppeteer and returns a PDF buffer. + * @param {object} violation - Row from violations JOIN employees + * @param {object} score - Row from active_cpas_scores + * @returns {Buffer} + */ +async function generatePdf(violation, score) { + const html = buildHtml(violation, score); + + const browser = await puppeteer.launch({ + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ], + headless: 'new', + }); + + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'networkidle0' }); + + const pdf = await page.pdf({ + format: 'Letter', + printBackground: true, + margin: { + top: '0.6in', + bottom: '0.7in', + left: '0.75in', + right: '0.75in', + }, + displayHeaderFooter: true, + headerTemplate: '
', + footerTemplate: ` +
+ CONFIDENTIAL — MPM Internal HR Document  |  + Page of +
`, + }); + + return pdf; + } finally { + await browser.close(); + } +} + +module.exports = generatePdf; diff --git a/pdf/template.js b/pdf/template.js new file mode 100755 index 0000000..9dd921e --- /dev/null +++ b/pdf/template.js @@ -0,0 +1,272 @@ +/** + * Builds the full HTML string for a CPAS violation PDF document. + * Matches the styling of the original HTML violation form. + */ + +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' }, +]; + +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 formatDateTime(d, t) { + const date = formatDate(d); + return t ? `${date} at ${t}` : date; +} + +function row(label, value) { + return ` + + ${label} + ${value || '—'} + `; +} + +function buildHtml(v, score) { + const activePts = score.active_points || 0; + const tier = getTier(activePts); + const newTotal = activePts + v.points; + const newTier = getTier(newTotal); + const tierChange = tier.label !== newTier.label; + + const generatedAt = new Date().toLocaleString('en-US', { + timeZone: 'America/Chicago', + dateStyle: 'full', timeStyle: 'short' + }); + + return ` + + + + + + + + +
+
+ Document ID: CPAS-${v.id.toString().padStart(5,'0')}
+ Generated: ${generatedAt} +
+

CPAS Individual Violation Record

+

Message Point Media — Confidential HR Document

+
+ +
+ +
+ ⚠ 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', v.violation_type.replace(/_/g,' ').replace(/\w/g,c=>c.toUpperCase()))} + ${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))} + ${v.location ? row('Location / Context', v.location) : ''} + ${v.details ? row('Incident Details', `${v.details}`) : ''} + ${row('Submitted By', v.submitted_by || 'System')} +
+
+ + +
+
CPAS Point Assessment
+ +
+
${v.points}
+
Points Assessed — This Violation
+
+ +
+
+
${activePts}
+
Active Points (Prior)
+
+ ${tier.label} +
+
+
+
+
+
${v.points}
+
Points — This Violation
+
+
=
+
+
${newTotal}
+
New Active Total
+
+ ${newTier.label} +
+
+
+ + ${tierChange ? ` +
+ ⚠ Tier Escalation: This violation advances the employee from + ${tier.label} to ${newTier.label}. + Review associated tier consequences per the Employee Handbook. +
` : ''} +
+ + +
+
CPAS Tier Reference
+ + + + + + ${TIERS.map(t => ` + + + + `).join('')} +
PointsTier
${t.min === 30 ? '30+' : t.min + '–' + t.max}${t.label}
+
+ + +
+ 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, + Chapter 4, Section 5. This document should be reviewed with the employee and signed by all parties. +
+ + +
+
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. +

+
+
+
+
Employee Signature
+
+
+
Date
+
+
+
+
+
Supervisor / Documenting Officer Signature
+
+
+
Date
+
+
+
+
+
+ + + +
+ +`; +} + +module.exports = buildHtml; diff --git a/server.js b/server.js index ff4bf33..8cab0f3 100755 --- a/server.js +++ b/server.js @@ -1,7 +1,8 @@ -const express = require('express'); -const cors = require('cors'); -const path = require('path'); -const db = require('./db/database'); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const db = require('./db/database'); +const generatePdf = require('./pdf/generator'); const app = express(); const PORT = process.env.PORT || 3001; @@ -47,7 +48,7 @@ app.post('/api/employees', (req, res) => { res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor }); }); -// ── Employee CPAS Score (rolling 90-day) ─────────────────────────────────── +// ── Employee CPAS Score ──────────────────────────────────────────────────── app.get('/api/employees/:employeeId/score', (req, res) => { const row = db.prepare( 'SELECT * FROM active_cpas_scores WHERE employee_id = ?' @@ -55,8 +56,7 @@ app.get('/api/employees/:employeeId/score', (req, res) => { res.json(row || { employee_id: req.params.employeeId, active_points: 0, violation_count: 0 }); }); -// ── Violation type usage counts for an employee (90-day window) ──────────── -// Returns { violation_type: count } so the frontend can badge the dropdown +// ── Violation type counts (90-day) ───────────────────────────────────────── app.get('/api/employees/:employeeId/violation-counts', (req, res) => { const rows = db.prepare(` SELECT violation_type, COUNT(*) as count @@ -65,13 +65,12 @@ app.get('/api/employees/:employeeId/violation-counts', (req, res) => { AND incident_date >= DATE('now', '-90 days') GROUP BY violation_type `).all(req.params.employeeId); - const map = {}; rows.forEach(r => { map[r.violation_type] = r.count; }); res.json(map); }); -// ── All-time violation type counts for recidivist point suggestion ───────── +// ── Violation type counts (all-time) ─────────────────────────────────────── app.get('/api/employees/:employeeId/violation-counts/alltime', (req, res) => { const rows = db.prepare(` SELECT violation_type, COUNT(*) as count, MAX(points) as max_points_used @@ -79,13 +78,12 @@ app.get('/api/employees/:employeeId/violation-counts/alltime', (req, res) => { WHERE employee_id = ? GROUP BY violation_type `).all(req.params.employeeId); - const map = {}; rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; }); res.json(map); }); -// ── Violation history for an employee ───────────────────────────────────── +// ── Violation history ────────────────────────────────────────────────────── app.get('/api/violations/employee/:employeeId', (req, res) => { const limit = parseInt(req.query.limit) || 50; const rows = db.prepare(` @@ -127,6 +125,40 @@ app.post('/api/violations', (req, res) => { res.status(201).json({ id: result.lastInsertRowid }); }); +// ── PDF Generation ───────────────────────────────────────────────────────── +// GET /api/violations/:id/pdf +// Returns a binary PDF of the violation document +app.get('/api/violations/:id/pdf', async (req, res) => { + try { + const violation = db.prepare(` + SELECT v.*, e.name as employee_name, e.department, e.supervisor + FROM violations v + JOIN employees e ON e.id = v.employee_id + WHERE v.id = ? + `).get(req.params.id); + + if (!violation) return res.status(404).json({ error: 'Violation not found' }); + + // Pull employee 90-day score for context block in PDF + const score = db.prepare( + 'SELECT * FROM active_cpas_scores WHERE employee_id = ?' + ).get(violation.employee_id) || { active_points: 0, violation_count: 0 }; + + const pdfBuffer = await generatePdf(violation, score); + + const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_'); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`, + 'Content-Length': pdfBuffer.length, + }); + res.end(pdfBuffer); + } catch (err) { + console.error('[PDF] Error:', err); + res.status(500).json({ error: 'PDF generation failed', detail: err.message }); + } +}); + // ── SPA fallback ─────────────────────────────────────────────────────────── app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')); -- 2.52.0 From 050bd55bc1f2aeb32bd11c0b5ea48a22ce15ca62 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 12:24:17 -0600 Subject: [PATCH 002/106] Upload files to "/" --- Dockerfile | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5937568..e400493 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,12 +20,10 @@ RUN cd client && npm run build # ───────────────────────────────────────────────────────────────────────────── # Stage 2: Production image -# Copies only what's needed to run — no dev tools, no node_modules for client. -# Final image is lean (~180MB) and ready to run with zero host setup. # ───────────────────────────────────────────────────────────────────────────── FROM node:20-alpine AS production -# Chromium + deps for Phase 3 Puppeteer PDF generation +# Chromium for Puppeteer PDF generation RUN apk add --no-cache \ chromium \ nss \ @@ -42,16 +40,17 @@ ENV DB_PATH=/data/cpas.db WORKDIR /app -# Copy backend deps from builder +# Copy backend node_modules and compiled frontend from builder COPY --from=builder /build/node_modules ./node_modules COPY --from=builder /build/client/dist ./client/dist -# Copy backend source -COPY server.js ./ -COPY db/ ./db/ +# Copy all backend source files +COPY server.js ./ COPY package.json ./ +COPY db/ ./db/ +COPY pdf/ ./pdf/ -# Ensure data directory exists (will be overridden by volume mount) +# Ensure data directory exists RUN mkdir -p /data EXPOSE 3001 -- 2.52.0 From 740f328beb05f7637559e1c57cd5d46c407e2758 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 12:27:55 -0600 Subject: [PATCH 003/106] Upload files to "pdf" --- pdf/generator.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pdf/generator.js b/pdf/generator.js index c230aac..afb37a6 100755 --- a/pdf/generator.js +++ b/pdf/generator.js @@ -1,8 +1,9 @@ -const puppeteer = require('puppeteer'); -const buildHtml = require('./template'); +const puppeteer = require('puppeteer-core'); +const buildHtml = require('./template'); /** * Renders the violation document HTML via Puppeteer and returns a PDF buffer. + * Uses the system Chromium installed in the Alpine image (no separate download). * @param {object} violation - Row from violations JOIN employees * @param {object} score - Row from active_cpas_scores * @returns {Buffer} @@ -26,8 +27,8 @@ async function generatePdf(violation, score) { await page.setContent(html, { waitUntil: 'networkidle0' }); const pdf = await page.pdf({ - format: 'Letter', - printBackground: true, + format: 'Letter', + printBackground: true, margin: { top: '0.6in', bottom: '0.7in', -- 2.52.0 From 3279a5c60e5abe0d8e77cf05431007ebf7013d70 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Mar 2026 12:36:43 -0600 Subject: [PATCH 004/106] Upload files to "pdf" --- pdf/template.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/pdf/template.js b/pdf/template.js index 9dd921e..02e67d6 100755 --- a/pdf/template.js +++ b/pdf/template.js @@ -51,6 +51,17 @@ function buildHtml(v, score) { dateStyle: 'full', timeStyle: 'short' }); + // Map violation_type to handbook chapter reference (loaded from violations.js in frontend) + // Since we're in backend, we'll reconstruct key descriptions from the violation_name + // The database already stores violation_name (e.g., "Tardy Core Hours") and category + + // Build a contextual description block + const contextBlock = v.details + ? `
+ Context: ${v.details} +
` + : ''; + return ` @@ -120,6 +131,11 @@ function buildHtml(v, score) { background: #e7f3ff; border-left: 4px solid #2196F3; padding: 10px 14px; margin: 16px 0; font-size: 12px; } + .policy-context { + background: #f8f9fa; border-left: 3px solid #667eea; + padding: 12px 16px; margin: 12px 0; font-size: 12px; color: #444; + border-radius: 4px; + } @@ -157,12 +173,17 @@ function buildHtml(v, score) { ${row('Violation Type', `${v.violation_name}`)} ${row('Category', v.category)} - ${row('Policy Reference', v.violation_type.replace(/_/g,' ').replace(/\w/g,c=>c.toUpperCase()))} + ${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) : ''} - ${v.details ? row('Incident Details', `${v.details}`) : ''} ${row('Submitted By', v.submitted_by || 'System')}
+ + ${v.details ? ` +
+ Incident Details:
+ ${v.details} +
` : ''}
-- 2.52.0 From 333cad41d7929a5d959a1f2c3b96b5c52ef2378d Mon Sep 17 00:00:00 2001 From: Jason UNRAID Date: Fri, 6 Mar 2026 12:53:40 -0600 Subject: [PATCH 005/106] Phase 4 --- client/src/App.jsx | 77 +++----- client/src/components/Dashboard.jsx | 173 +++++++++++++++++ client/src/components/EmployeeModal.jsx | 245 ++++++++++++++++++++++++ client/src/components/NegateModal.jsx | 68 +++++++ db/database.js | 29 ++- db/schema.sql | 51 +++-- server.js | 197 ++++++++++--------- 7 files changed, 671 insertions(+), 169 deletions(-) create mode 100755 client/src/components/Dashboard.jsx create mode 100755 client/src/components/EmployeeModal.jsx create mode 100755 client/src/components/NegateModal.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 7392d7f..683e43d 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,56 +1,39 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import ViolationForm from './components/ViolationForm'; +import Dashboard from './components/Dashboard'; -const styles = { - body: { - fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - minHeight: '100vh', - padding: '20px', - margin: 0, - }, - container: { - maxWidth: '1200px', - margin: '0 auto', - background: 'white', - borderRadius: '12px', - boxShadow: '0 20px 60px rgba(0,0,0,0.3)', - overflow: 'hidden', - }, - header: { - background: 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)', - color: 'white', - padding: '30px', - textAlign: 'center', - }, - statusBar: { - fontSize: '11px', - color: '#aaa', - marginTop: '6px', - } +const tabs = [ + { id: 'dashboard', label: '📊 Dashboard' }, + { id: 'violation', label: '+ New Violation' }, +]; + +const s = { + app: { minHeight: '100vh', background: '#f5f6fa', fontFamily: "'Segoe UI', Arial, sans-serif" }, + nav: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0 }, + logo: { color: 'white', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px', marginRight: '32px', padding: '18px 0' }, + tab: (active) => ({ + padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)', + borderBottom: active ? '3px solid #667eea' : '3px solid transparent', + cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', + background: 'none', border: 'none', borderBottom: active ? '3px solid #667eea' : '3px solid transparent', + }), + card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }, }; export default function App() { - const [apiStatus, setApiStatus] = useState('checking...'); - - useEffect(() => { - fetch('/api/health') - .then(r => r.json()) - .then(() => setApiStatus('● API connected')) - .catch(() => setApiStatus('⚠ API unreachable')); - }, []); - + const [tab, setTab] = useState('dashboard'); return ( -
-
-
-

CPAS Violation Documentation System

-

- Generate Individual Violation Records with Contextual Fields -

-

{apiStatus}

-
- +
+ +
+ {tab === 'dashboard' ? : }
); diff --git a/client/src/components/Dashboard.jsx b/client/src/components/Dashboard.jsx new file mode 100755 index 0000000..33f6b81 --- /dev/null +++ b/client/src/components/Dashboard.jsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import CpasBadge, { getTier } from './CpasBadge'; +import EmployeeModal from './EmployeeModal'; + +const AT_RISK_THRESHOLD = 2; // points within next tier boundary + +const TIERS = [ + { min: 0, max: 4 }, + { min: 5, max: 9 }, + { min: 10, max: 14 }, + { min: 15, max: 19 }, + { min: 20, max: 24 }, + { min: 25, max: 29 }, + { min: 30, max: 999}, +]; + +function nextTierBoundary(points) { + for (const t of TIERS) { + if (points >= t.min && points <= t.max && t.max < 999) + return t.max + 1; + } + return null; +} + +function isAtRisk(points) { + const boundary = nextTierBoundary(points); + return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD; +} + +const s = { + wrap: { padding: '40px' }, + header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' }, + title: { fontSize: '24px', fontWeight: 700, color: '#2c3e50' }, + subtitle: { fontSize: '13px', color: '#888', marginTop: '3px' }, + statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' }, + statCard: { flex: '1', minWidth: '140px', background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: '8px', padding: '16px', textAlign: 'center' }, + statNum: { fontSize: '28px', fontWeight: 800, color: '#2c3e50' }, + statLbl: { fontSize: '11px', color: '#888', marginTop: '4px' }, + search: { padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', width: '260px' }, + table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }, + th: { background: '#34495e', color: 'white', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }, + td: { padding: '11px 14px', borderBottom: '1px solid #f0f0f0', fontSize: '13px', verticalAlign: 'middle' }, + nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#667eea', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' }, + atRiskBadge: { display: 'inline-block', marginLeft: '8px', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#fff3cd', color: '#856404', border: '1px solid #ffc107', verticalAlign: 'middle' }, + zeroRow: { color: '#aaa', fontStyle: 'italic', fontSize: '12px' }, + refreshBtn:{ padding: '9px 18px', background: '#667eea', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }, +}; + +export default function Dashboard() { + const [employees, setEmployees] = useState([]); + const [filtered, setFiltered] = useState([]); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(() => { + setLoading(true); + axios.get('/api/dashboard') + .then(r => { setEmployees(r.data); setFiltered(r.data); }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + useEffect(() => { + const q = search.toLowerCase(); + setFiltered(employees.filter(e => + e.name.toLowerCase().includes(q) || + (e.department || '').toLowerCase().includes(q) || + (e.supervisor || '').toLowerCase().includes(q) + )); + }, [search, employees]); + + const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length; + const activeCount = employees.filter(e => e.active_points > 0).length; + const cleanCount = employees.filter(e => e.active_points === 0).length; + const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0); + + return ( +
+
+
+
Company Dashboard
+
Click any employee name to view their full profile
+
+
+ setSearch(e.target.value)} /> + +
+
+ + {/* ── Stat cards ───────────────────────────────────────── */} +
+
+
{employees.length}
+
Total Employees
+
+
+
{cleanCount}
+
Elite Standing (0 pts)
+
+
+
{activeCount}
+
With Active Points
+
+
+
{atRiskCount}
+
At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)
+
+
+
{maxPoints}
+
Highest Active Score
+
+
+ + {/* ── Scoreboard table ─────────────────────────────────── */} + {loading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map((emp, i) => { + const risk = isAtRisk(emp.active_points); + const tier = getTier(emp.active_points); + const boundary = nextTierBoundary(emp.active_points); + return ( + + + + + + + + + + ); + })} + +
#EmployeeDepartmentSupervisorTier / StandingActive Points90-Day Violations
No employees found.
{i + 1} + + {risk && ( + + ⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()} + + )} + {emp.department || '—'}{emp.supervisor || '—'}{emp.active_points}{emp.violation_count}
+ )} + + {/* ── Employee profile modal ───────────────────────────── */} + {selectedId && ( + { setSelectedId(null); load(); }} + /> + )} +
+ ); +} diff --git a/client/src/components/EmployeeModal.jsx b/client/src/components/EmployeeModal.jsx new file mode 100755 index 0000000..ac4b334 --- /dev/null +++ b/client/src/components/EmployeeModal.jsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import CpasBadge, { getTier } from './CpasBadge'; +import NegateModal from './NegateModal'; + +const s = { + overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end' }, + panel: { background: 'white', width: '680px', maxWidth: '95vw', height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }, + header: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', color: 'white', padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10 }, + closeBtn: { float: 'right', background: 'none', border: 'none', color: 'white', fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px' }, + body: { padding: '24px 28px', flex: 1 }, + scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' }, + scoreCard: { flex: '1', minWidth: '100px', background: '#f8f9fa', borderRadius: '8px', padding: '14px', textAlign: 'center', border: '1px solid #dee2e6' }, + scoreNum: { fontSize: '26px', fontWeight: 800 }, + scoreLbl: { fontSize: '11px', color: '#888', marginTop: '3px' }, + sectionHd: { fontSize: '13px', fontWeight: 700, color: '#34495e', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '10px', marginTop: '24px' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' }, + th: { background: '#f1f3f5', padding: '8px 10px', textAlign: 'left', color: '#555', fontWeight: 600, fontSize: '11px', textTransform: 'uppercase' }, + td: { padding: '9px 10px', borderBottom: '1px solid #f0f0f0', verticalAlign: 'top' }, + negatedRow: { background: '#f8f8f8', color: '#aaa' }, + actionBtn: (color) => ({ background: 'none', border: `1px solid ${color}`, color, borderRadius: '4px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', marginRight: '4px', fontWeight: 600 }), + resTag: { display: 'inline-block', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' }, + pdfBtn: { background: 'none', border: '1px solid #667eea', color: '#667eea', borderRadius: '4px', padding: '3px 8px', fontSize: '11px', cursor: 'pointer', fontWeight: 600 }, + deleteConfirm: { background: '#f8d7da', border: '1px solid #f5c6cb', borderRadius: '6px', padding: '12px', marginTop: '8px', fontSize: '12px' }, +}; + +const RESOLUTION_TYPES = [ + 'Corrective Training Completed', + 'Management Discretion', + 'Data Entry Error', + 'Successfully Appealed', +]; + +export default function EmployeeModal({ employeeId, onClose }) { + const [employee, setEmployee] = useState(null); + const [score, setScore] = useState(null); + const [violations, setViolations] = useState([]); + const [loading, setLoading] = useState(true); + const [negating, setNegating] = useState(null); // violation object being soft-negated + const [confirmDel, setConfirmDel] = useState(null); // violation id pending hard delete + + const load = useCallback(() => { + setLoading(true); + Promise.all([ + axios.get('/api/employees'), + axios.get(`/api/employees/${employeeId}/score`), + axios.get(`/api/violations/employee/${employeeId}?limit=100`), + ]).then(([empRes, scoreRes, violRes]) => { + const emp = empRes.data.find(e => e.id === employeeId); + setEmployee(emp || null); + setScore(scoreRes.data); + setViolations(violRes.data); + }).finally(() => setLoading(false)); + }, [employeeId]); + + useEffect(() => { load(); }, [load]); + + const handleDownloadPdf = async (violId, empName, date) => { + const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' }); + const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' })); + const link = document.createElement('a'); + link.href = url; + link.download = `CPAS_${(empName||'').replace(/[^a-z0-9]/gi,'_')}_${date}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }; + + const handleHardDelete = async (id) => { + await axios.delete(`/api/violations/${id}`); + setConfirmDel(null); + load(); + }; + + const handleRestore = async (id) => { + await axios.patch(`/api/violations/${id}/restore`); + load(); + }; + + const tier = score ? getTier(score.active_points) : null; + const active = violations.filter(v => !v.negated); + const negated = violations.filter(v => v.negated); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+ + {/* ── Header ──────────────────────────────────── */} +
+ +
+ {loading ? 'Loading…' : (employee?.name || 'Employee Profile')} +
+ {employee && ( +
+ {[employee.department, employee.supervisor ? `Supervisor: ${employee.supervisor}` : null].filter(Boolean).join(' · ')} +
+ )} +
+ +
+ {loading ? ( +

Loading…

+ ) : (<> + + {/* ── Score cards ───────────────────────── */} +
+
+
{score?.active_points ?? 0}
+
Active Points
+
+
+
{score?.violation_count ?? 0}
+
90-Day Violations
+
+
+
{active.length}
+
Total On Record
+
+
+
{negated.length}
+
Negated
+
+
+ + {tier && ( +
+ {tier.label} + Rolling 90-day window · Points expire automatically +
+ )} + + {/* ── Active violations ─────────────────── */} +
Active Violations
+ {active.length === 0 ? ( +

No active violations on record.

+ ) : ( + + + + + + + + + + + {active.map(v => ( + + + + + + + ))} + +
DateViolationPtsActions
{v.incident_date} +
{v.violation_name}
+
{v.category}
+ {v.details &&
{v.details}
} +
{v.points} + + +
+ {confirmDel === v.id ? ( +
+ Permanently delete? This cannot be undone. +
+ + +
+
+ ) : ( + + )} +
+ )} + + {/* ── Negated violations ────────────────── */} + {negated.length > 0 && (<> +
Negated / Resolved Violations
+ + + + + + + + + + + + {negated.map(v => ( + + + + + + + + ))} + +
DateViolationPtsResolutionActions
{v.incident_date} +
{v.violation_name}
+
{v.category}
+
{v.points} + {v.resolution_type} + {v.resolution_details &&
{v.resolution_details}
} + {v.resolved_by &&
by {v.resolved_by}
} +
+ + {confirmDel === v.id ? ( +
+ Permanently delete? +
+ + +
+
+ ) : ( + + )} +
+ )} + + )} +
+
+ + {/* ── Negate sub-modal ────────────────────────────────── */} + {negating && ( + { + await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by }); + setNegating(null); + load(); + }} + onCancel={() => setNegating(null)} + /> + )} +
+ ); +} diff --git a/client/src/components/NegateModal.jsx b/client/src/components/NegateModal.jsx new file mode 100755 index 0000000..1ea38a1 --- /dev/null +++ b/client/src/components/NegateModal.jsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; + +const RESOLUTION_TYPES = [ + 'Corrective Training Completed', + 'Management Discretion', + 'Data Entry Error', + 'Successfully Appealed', +]; + +const s = { + overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.65)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }, + box: { background: 'white', borderRadius: '10px', padding: '28px', width: '440px', maxWidth: '95vw', boxShadow: '0 8px 32px rgba(0,0,0,0.22)' }, + title: { fontSize: '17px', fontWeight: 700, color: '#2c3e50', marginBottom: '6px' }, + sub: { fontSize: '12px', color: '#888', marginBottom: '20px' }, + label: { fontWeight: 600, color: '#555', fontSize: '12px', marginBottom: '5px', display: 'block' }, + input: { width: '100%', padding: '9px 12px', border: '1px solid #ddd', borderRadius: '5px', fontSize: '13px', fontFamily: 'inherit', marginBottom: '14px' }, + btnRow: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '8px' }, + btnOk: { padding: '10px 22px', background: '#856404', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 700, fontSize: '13px' }, + btnCancel:{ padding: '10px 22px', background: '#f1f3f5', color: '#555', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }, + violBox: { background: '#fff3cd', border: '1px solid #ffc107', borderRadius: '6px', padding: '10px 14px', marginBottom: '18px', fontSize: '13px' }, +}; + +export default function NegateModal({ violation, onConfirm, onCancel }) { + const [resType, setResType] = useState(''); + const [details, setDetails] = useState(''); + const [resolvedBy, setResolvedBy] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = () => { + if (!resType) { setError('Please select a resolution type.'); return; } + onConfirm({ resolution_type: resType, details, resolved_by: resolvedBy }); + }; + + return ( +
+
+
⊘ Negate Violation Points
+
This will zero out the points from this incident. The record remains in the audit log.
+ +
+ {violation.violation_name}  ·  {violation.points} pts  ·  {violation.incident_date} +
+ + + + + +