Phase 3 #2

Merged
jason merged 1 commits from p3 into master 2026-03-06 12:20:51 -06:00
5 changed files with 438 additions and 49 deletions
Showing only changes of commit 62b142d4a3 - Show all commits

View File

@@ -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' },
@@ -40,20 +41,19 @@ export default function ViolationForm() {
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,
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 (
<div style={s.content}>
{/* ── Employee Information ────────────────────────────────── */}
{/* ── Employee Information ─────────────────────────────── */}
<div style={s.section}>
<h2 style={s.sectionTitle}>Employee Information</h2>
{/* CPAS score banner — shown once employee is selected */}
{intel.score && form.employeeId && (
<div style={s.scoreRow}>
<span style={{ fontSize: '13px', color: '#555', fontWeight: 600 }}>Current Standing:</span>
@@ -145,13 +174,12 @@ export default function ViolationForm() {
</div>
</div>
{/* ── Violation Details ────────────────────────────────────── */}
{/* ── Violation Details ────────────────────────────────── */}
<form onSubmit={handleSubmit}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Violation Details</h2>
<div style={s.grid}>
{/* Violation type dropdown with prior-use badges */}
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
@@ -170,7 +198,6 @@ export default function ViolationForm() {
))}
</select>
{/* Handbook definition */}
{violation && (
<div style={s.contextBox}>
<strong>{violation.name}</strong>
@@ -179,13 +206,11 @@ export default function ViolationForm() {
Repeat {intel.countsAllTime[form.violationType]?.count}x prior
</span>
)}
<br />
{violation.description}<br />
<br />{violation.description}<br />
<span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span>
</div>
)}
{/* Recidivist auto-suggest notice */}
{violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
<div style={s.repeatWarn}>
<strong>Repeat offense detected.</strong> Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed.
@@ -193,7 +218,6 @@ export default function ViolationForm() {
)}
</div>
{/* Incident date */}
<div style={s.item}>
<label style={s.label}>Incident Date:</label>
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
@@ -231,7 +255,6 @@ export default function ViolationForm() {
)}
</div>
{/* Tier escalation warning */}
{intel.score && violation && (
<TierWarning
currentPoints={intel.score.active_points}
@@ -239,7 +262,6 @@ export default function ViolationForm() {
/>
)}
{/* Point slider */}
{violation && (
<div style={s.pointBox}>
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
@@ -248,33 +270,43 @@ export default function ViolationForm() {
? `${violation.minPoints} Points (Fixed)`
: `${violation.minPoints}${violation.maxPoints} Points`}
</p>
<input
style={{ width: '100%', marginTop: '10px' }}
type="range" name="points"
<input style={{ width: '100%', marginTop: '10px' }} type="range" name="points"
min={violation.minPoints} max={violation.maxPoints}
value={form.points} onChange={handleChange}
/>
value={form.points} onChange={handleChange} />
<div style={s.pointValue}>{form.points} Points</div>
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
</div>
)}
</div>
<div style={s.note}>
<strong>Note:</strong> Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3.
</div>
<div style={s.btnRow}>
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); }}>
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); setLastViolId(null); }}>
Clear Form
</button>
</div>
{/* PDF download — appears after successful submission */}
{lastViolId && status?.ok && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<button
type="button"
style={{ ...s.btnPdf, opacity: pdfLoading ? 0.7 : 1 }}
onClick={handleDownloadPdf}
disabled={pdfLoading}
>
{pdfLoading ? '⏳ Generating PDF...' : '⬇ Download PDF'}
</button>
<p style={{ fontSize: '11px', color: '#888', marginTop: '6px' }}>
Violation #{lastViolId} click to download the signed violation document
</p>
</div>
)}
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
</form>
{/* ── Violation History Panel ──────────────────────────────── */}
{/* ── Violation History Panel ──────────────────────────── */}
{form.employeeId && (
<div style={s.section}>
<h2 style={s.sectionTitle}>Violation History</h2>

View File

@@ -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"

52
pdf/generator.js Executable file
View File

@@ -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: '<div></div>',
footerTemplate: `
<div style="font-size:9px; color:#888; width:100%; text-align:center; padding:0 0.75in;">
CONFIDENTIAL — MPM Internal HR Document &nbsp;|&nbsp;
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>`,
});
return pdf;
} finally {
await browser.close();
}
}
module.exports = generatePdf;

272
pdf/template.js Executable file
View File

@@ -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 `
<tr>
<td style="font-weight:600; color:#555; width:200px; padding:8px 12px; border-bottom:1px solid #eee; white-space:nowrap;">${label}</td>
<td style="padding:8px 12px; border-bottom:1px solid #eee; color:#222;">${value || '—'}</td>
</tr>`;
}
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #222; background: #fff; }
.header { background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 28px 32px; }
.header h1 { font-size: 22px; letter-spacing: 0.5px; }
.header p { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.doc-id { float: right; text-align: right; font-size: 11px; opacity: 0.75; }
.section { margin: 20px 0; }
.section-title {
font-size: 14px; font-weight: 700; color: white;
background: #34495e; padding: 8px 14px;
border-radius: 4px 4px 0 0; margin-bottom: 0;
}
table { width: 100%; border-collapse: collapse; border: 1px solid #ddd; border-top: none; }
.score-box {
display: flex; gap: 20px; flex-wrap: wrap;
background: #f8f9fa; border: 1px solid #ddd;
border-radius: 6px; padding: 16px 20px; margin: 20px 0;
}
.score-cell { flex: 1; min-width: 120px; text-align: center; }
.score-num { font-size: 28px; font-weight: 800; }
.score-lbl { font-size: 11px; color: #666; margin-top: 2px; }
.tier-badge {
display: inline-block; padding: 5px 14px;
border-radius: 14px; font-size: 12px; font-weight: 700;
border: 2px solid currentColor;
}
.tier-change {
background: #fff3cd; border: 2px solid #ffc107;
border-radius: 6px; padding: 12px 16px; margin: 16px 0;
font-size: 12px; color: #856404;
}
.points-display {
background: #fff3cd; border: 2px solid #ffc107;
border-radius: 6px; padding: 16px; margin: 16px 0; text-align: center;
}
.points-display .pts { font-size: 36px; font-weight: 800; color: #667eea; }
.points-display .lbl { font-size: 12px; color: #666; }
.sig-section { margin-top: 30px; }
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 16px; }
.sig-block { border-top: 1px solid #333; padding-top: 6px; }
.sig-label { font-size: 11px; color: #555; }
.footer-bar {
margin-top: 30px; padding: 10px 0;
border-top: 2px solid #2c3e50;
font-size: 10px; color: #888; text-align: center;
}
.confidential {
background: #f8d7da; border: 1px solid #f5c6cb;
border-radius: 4px; padding: 6px 12px;
font-size: 11px; color: #721c24; font-weight: 600;
text-align: center; margin-bottom: 16px;
}
.notice {
background: #e7f3ff; border-left: 4px solid #2196F3;
padding: 10px 14px; margin: 16px 0; font-size: 12px;
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="doc-id">
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
Generated: ${generatedAt}
</div>
<h1>CPAS Individual Violation Record</h1>
<p>Message Point Media — Confidential HR Document</p>
</div>
<div style="padding: 0 4px;">
<div class="confidential" style="margin-top:16px;">
⚠ CONFIDENTIAL — For authorized HR and management use only
</div>
<!-- Employee Information -->
<div class="section">
<div class="section-title">Employee Information</div>
<table>
${row('Employee Name', `<strong>${v.employee_name}</strong>`)}
${row('Department', v.department)}
${row('Supervisor', v.supervisor)}
${row('Witness / Documenting Officer', v.witness_name)}
</table>
</div>
<!-- Violation Details -->
<div class="section">
<div class="section-title">Violation Details</div>
<table>
${row('Violation Type', `<strong>${v.violation_name}</strong>`)}
${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', `<em>${v.details}</em>`) : ''}
${row('Submitted By', v.submitted_by || 'System')}
</table>
</div>
<!-- CPAS Point Assessment -->
<div class="section">
<div class="section-title">CPAS Point Assessment</div>
<div class="points-display">
<div class="pts">${v.points}</div>
<div class="lbl">Points Assessed — This Violation</div>
</div>
<div class="score-box">
<div class="score-cell">
<div class="score-num" style="color:${tier.color};">${activePts}</div>
<div class="score-lbl">Active Points (Prior)</div>
<div style="margin-top:6px;">
<span class="tier-badge" style="color:${tier.color};">${tier.label}</span>
</div>
</div>
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">+</div>
<div class="score-cell">
<div class="score-num" style="color:#667eea;">${v.points}</div>
<div class="score-lbl">Points — This Violation</div>
</div>
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">=</div>
<div class="score-cell">
<div class="score-num" style="color:${newTier.color};">${newTotal}</div>
<div class="score-lbl">New Active Total</div>
<div style="margin-top:6px;">
<span class="tier-badge" style="color:${newTier.color};">${newTier.label}</span>
</div>
</div>
</div>
${tierChange ? `
<div class="tier-change">
<strong>⚠ Tier Escalation:</strong> This violation advances the employee from
<strong>${tier.label}</strong> to <strong>${newTier.label}</strong>.
Review associated tier consequences per the Employee Handbook.
</div>` : ''}
</div>
<!-- CPAS Tier Reference -->
<div class="section">
<div class="section-title">CPAS Tier Reference</div>
<table>
<tr style="background:#f8f9fa;">
<th style="padding:7px 12px; text-align:left; font-size:12px;">Points</th>
<th style="padding:7px 12px; text-align:left; font-size:12px;">Tier</th>
</tr>
${TIERS.map(t => `
<tr style="${newTotal >= t.min && newTotal <= t.max ? 'background:#fff3cd; font-weight:700;' : ''}">
<td style="padding:6px 12px; border-bottom:1px solid #eee; font-size:12px;">${t.min === 30 ? '30+' : t.min + '' + t.max}</td>
<td style="padding:6px 12px; border-bottom:1px solid #eee; font-size:12px; color:${t.color};">${t.label}</td>
</tr>`).join('')}
</table>
</div>
<!-- Notice -->
<div class="notice">
<strong>Employee Notice:</strong> 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.
</div>
<!-- Signatures -->
<div class="sig-section">
<div class="section-title" style="background:#34495e; color:white; padding:8px 14px; border-radius:4px; font-size:14px; font-weight:700;">Acknowledgement & Signatures</div>
<div style="padding: 12px 0;">
<p style="font-size:12px; color:#555; margin-bottom:20px;">
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.
</p>
<div class="sig-grid">
<div>
<div class="sig-block">
<div class="sig-label">Employee Signature</div>
</div>
<div style="margin-top:20px;" class="sig-block">
<div class="sig-label">Date</div>
</div>
</div>
<div>
<div class="sig-block">
<div class="sig-label">Supervisor / Documenting Officer Signature</div>
</div>
<div style="margin-top:20px;" class="sig-block">
<div class="sig-label">Date</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer-bar">
CPAS Violation Record — Document ID: CPAS-${v.id.toString().padStart(5,'0')} &nbsp;|&nbsp;
${v.employee_name} &nbsp;|&nbsp; Incident: ${v.incident_date} &nbsp;|&nbsp;
Message Point Media Internal Use Only
</div>
</div><!-- /padding -->
</body>
</html>`;
}
module.exports = buildHtml;

View File

@@ -2,6 +2,7 @@ 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'));