- {/* 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
)}
+ {/* PDF download — appears after successful submission */}
+ {lastViolId && status?.ok && (
+
}
- {/* ── 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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚠ 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
+
+
+ | Points |
+ Tier |
+
+ ${TIERS.map(t => `
+
+ | ${t.min === 30 ? '30+' : t.min + '–' + t.max} |
+ ${t.label} |
+
`).join('')}
+
+
+
+
+
+ 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.
+
+
+
+
+
+
Supervisor / Documenting Officer Signature
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+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'));