From 62b142d4a376b221aae34d61220e9889355d93a1 Mon Sep 17 00:00:00 2001 From: Jason UNRAID Date: Fri, 6 Mar 2026 12:19:55 -0600 Subject: [PATCH] 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.49.1