Phase 3
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
52
pdf/generator.js
Executable 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 |
|
||||||
|
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
272
pdf/template.js
Executable 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')} |
|
||||||
|
${v.employee_name} | Incident: ${v.incident_date} |
|
||||||
|
Message Point Media Internal Use Only
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /padding -->
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = buildHtml;
|
||||||
54
server.js
54
server.js
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user