Compare commits

...

2 Commits

Author SHA1 Message Date
161a1fec3d Merge pull request 'Phase 3' (#2) from p3 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/2
2026-03-06 12:20:51 -06:00
62b142d4a3 Phase 3 2026-03-06 12:19:55 -06:00
5 changed files with 438 additions and 49 deletions

View File

@@ -23,6 +23,7 @@ const s = {
scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' }, scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' },
btnRow: { display: 'flex', gap: '15px', justifyContent: 'center', marginTop: '30px', 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' }, 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' }, 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' }, 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' }, 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() { export default function ViolationForm() {
const [employees, setEmployees] = useState([]); const [employees, setEmployees] = useState([]);
const [form, setForm] = useState(EMPTY_FORM); const [form, setForm] = useState(EMPTY_FORM);
const [violation, setViolation] = useState(null); const [violation, setViolation] = useState(null);
const [status, setStatus] = 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); const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => { useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {}); 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(() => { useEffect(() => {
if (!violation || !form.violationType) return; if (!violation || !form.violationType) return;
const allTime = intel.countsAllTime[form.violationType]; const allTime = intel.countsAllTime[form.violationType];
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) { if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
// Suggest max points for repeat offenders
setForm(prev => ({ ...prev, points: violation.maxPoints })); setForm(prev => ({ ...prev, points: violation.maxPoints }));
} else { } else {
setForm(prev => ({ ...prev, points: violation.minPoints })); setForm(prev => ({ ...prev, points: violation.minPoints }));
@@ -82,18 +82,26 @@ export default function ViolationForm() {
try { try {
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor }); const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
const employeeId = empRes.data.id; const employeeId = empRes.data.id;
await axios.post('/api/violations', { const violRes = await axios.post('/api/violations', {
employee_id: employeeId, violation_type: form.violationType, employee_id: employeeId,
violation_type: form.violationType,
violation_name: violation?.name || form.violationType, violation_name: violation?.name || form.violationType,
category: violation?.category || 'General', points: parseInt(form.points), category: violation?.category || 'General',
incident_date: form.incidentDate, incident_time: form.incidentTime || null, points: parseInt(form.points),
location: form.location || null, details: form.additionalDetails || null, incident_date: form.incidentDate,
witness_name: form.witnessName || null, 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'); const empList = await axios.get('/api/employees');
setEmployees(empList.data); 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); setForm(EMPTY_FORM);
setViolation(null); setViolation(null);
} catch (err) { } 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 showField = f => violation?.fields?.includes(f);
const priorCount90 = key => intel.counts90[key] || 0; const priorCount90 = key => intel.counts90[key] || 0;
const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1; const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1;
@@ -108,11 +138,10 @@ export default function ViolationForm() {
return ( return (
<div style={s.content}> <div style={s.content}>
{/* ── Employee Information ────────────────────────────────── */} {/* ── Employee Information ─────────────────────────────── */}
<div style={s.section}> <div style={s.section}>
<h2 style={s.sectionTitle}>Employee Information</h2> <h2 style={s.sectionTitle}>Employee Information</h2>
{/* CPAS score banner — shown once employee is selected */}
{intel.score && form.employeeId && ( {intel.score && form.employeeId && (
<div style={s.scoreRow}> <div style={s.scoreRow}>
<span style={{ fontSize: '13px', color: '#555', fontWeight: 600 }}>Current Standing:</span> <span style={{ fontSize: '13px', color: '#555', fontWeight: 600 }}>Current Standing:</span>
@@ -145,13 +174,12 @@ export default function ViolationForm() {
</div> </div>
</div> </div>
{/* ── Violation Details ────────────────────────────────────── */} {/* ── Violation Details ────────────────────────────────── */}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={s.section}> <div style={s.section}>
<h2 style={s.sectionTitle}>Violation Details</h2> <h2 style={s.sectionTitle}>Violation Details</h2>
<div style={s.grid}> <div style={s.grid}>
{/* Violation type dropdown with prior-use badges */}
<div style={{ ...s.item, ...s.fullCol }}> <div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label> <label style={s.label}>Violation Type:</label>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required> <select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
@@ -170,7 +198,6 @@ export default function ViolationForm() {
))} ))}
</select> </select>
{/* Handbook definition */}
{violation && ( {violation && (
<div style={s.contextBox}> <div style={s.contextBox}>
<strong>{violation.name}</strong> <strong>{violation.name}</strong>
@@ -179,13 +206,11 @@ export default function ViolationForm() {
Repeat {intel.countsAllTime[form.violationType]?.count}x prior Repeat {intel.countsAllTime[form.violationType]?.count}x prior
</span> </span>
)} )}
<br /> <br />{violation.description}<br />
{violation.description}<br />
<span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span> <span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span>
</div> </div>
)} )}
{/* Recidivist auto-suggest notice */}
{violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && ( {violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
<div style={s.repeatWarn}> <div style={s.repeatWarn}>
<strong>Repeat offense detected.</strong> Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed. <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> </div>
{/* Incident date */}
<div style={s.item}> <div style={s.item}>
<label style={s.label}>Incident Date:</label> <label style={s.label}>Incident Date:</label>
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required /> <input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
@@ -231,7 +255,6 @@ export default function ViolationForm() {
)} )}
</div> </div>
{/* Tier escalation warning */}
{intel.score && violation && ( {intel.score && violation && (
<TierWarning <TierWarning
currentPoints={intel.score.active_points} currentPoints={intel.score.active_points}
@@ -239,7 +262,6 @@ export default function ViolationForm() {
/> />
)} )}
{/* Point slider */}
{violation && ( {violation && (
<div style={s.pointBox}> <div style={s.pointBox}>
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4> <h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
@@ -248,33 +270,43 @@ export default function ViolationForm() {
? `${violation.minPoints} Points (Fixed)` ? `${violation.minPoints} Points (Fixed)`
: `${violation.minPoints}${violation.maxPoints} Points`} : `${violation.minPoints}${violation.maxPoints} Points`}
</p> </p>
<input <input style={{ width: '100%', marginTop: '10px' }} type="range" name="points"
style={{ width: '100%', marginTop: '10px' }}
type="range" name="points"
min={violation.minPoints} max={violation.maxPoints} min={violation.minPoints} max={violation.maxPoints}
value={form.points} onChange={handleChange} value={form.points} onChange={handleChange} />
/>
<div style={s.pointValue}>{form.points} Points</div> <div style={s.pointValue}>{form.points} Points</div>
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p> <p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
</div> </div>
)} )}
</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}> <div style={s.btnRow}>
<button type="submit" style={s.btnPrimary}>Submit Violation</button> <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 Clear Form
</button> </button>
</div> </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>} {status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
</form> </form>
{/* ── Violation History Panel ──────────────────────────────── */} {/* ── Violation History Panel ──────────────────────────── */}
{form.employeeId && ( {form.employeeId && (
<div style={s.section}> <div style={s.section}>
<h2 style={s.sectionTitle}>Violation History</h2> <h2 style={s.sectionTitle}>Violation History</h2>

View File

@@ -15,7 +15,8 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.3" "express": "^4.18.3",
"puppeteer-core": "^22.0.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.0" "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

@@ -1,7 +1,8 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const db = require('./db/database'); const db = require('./db/database');
const generatePdf = require('./pdf/generator');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; 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 }); 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) => { app.get('/api/employees/:employeeId/score', (req, res) => {
const row = db.prepare( const row = db.prepare(
'SELECT * FROM active_cpas_scores WHERE employee_id = ?' '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 }); res.json(row || { employee_id: req.params.employeeId, active_points: 0, violation_count: 0 });
}); });
// ── Violation type usage counts for an employee (90-day window) ──────────── // ── Violation type counts (90-day) ─────────────────────────────────────────
// Returns { violation_type: count } so the frontend can badge the dropdown
app.get('/api/employees/:employeeId/violation-counts', (req, res) => { app.get('/api/employees/:employeeId/violation-counts', (req, res) => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT violation_type, COUNT(*) as count 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') AND incident_date >= DATE('now', '-90 days')
GROUP BY violation_type GROUP BY violation_type
`).all(req.params.employeeId); `).all(req.params.employeeId);
const map = {}; const map = {};
rows.forEach(r => { map[r.violation_type] = r.count; }); rows.forEach(r => { map[r.violation_type] = r.count; });
res.json(map); 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) => { app.get('/api/employees/:employeeId/violation-counts/alltime', (req, res) => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT violation_type, COUNT(*) as count, MAX(points) as max_points_used 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 = ? WHERE employee_id = ?
GROUP BY violation_type GROUP BY violation_type
`).all(req.params.employeeId); `).all(req.params.employeeId);
const map = {}; const map = {};
rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; }); rows.forEach(r => { map[r.violation_type] = { count: r.count, max_points_used: r.max_points_used }; });
res.json(map); res.json(map);
}); });
// ── Violation history for an employee ───────────────────────────────────── // ── Violation history ──────────────────────────────────────────────────────
app.get('/api/violations/employee/:employeeId', (req, res) => { app.get('/api/violations/employee/:employeeId', (req, res) => {
const limit = parseInt(req.query.limit) || 50; const limit = parseInt(req.query.limit) || 50;
const rows = db.prepare(` const rows = db.prepare(`
@@ -127,6 +125,40 @@ app.post('/api/violations', (req, res) => {
res.status(201).json({ id: result.lastInsertRowid }); 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 ─────────────────────────────────────────────────────────── // ── SPA fallback ───────────────────────────────────────────────────────────
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')); res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'));