Compare commits

..

5 Commits

Author SHA1 Message Date
ecc9f3b9f6 Merge pull request 'p4-hotfixes' (#10) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/10
2026-03-06 14:13:15 -06:00
066f95cc88 Upload files to "pdf" 2026-03-06 14:12:00 -06:00
2383e3cc94 Upload files to "client/src/components" 2026-03-06 14:11:46 -06:00
8bbfd90f48 Upload files to "client/src" 2026-03-06 14:11:36 -06:00
590aae5cca Upload files to "/" 2026-03-06 14:11:19 -06:00
4 changed files with 378 additions and 416 deletions

View File

@@ -1,61 +1,28 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1: Builder
# Installs ALL dependencies and compiles the React frontend inside Docker.
# Nothing needs to be installed on the host machine except Docker itself.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /build WORKDIR /build
# Install backend deps
COPY package.json ./ COPY package.json ./
RUN npm install RUN npm install
# Install frontend deps and build React app
COPY client/package.json ./client/ COPY client/package.json ./client/
RUN cd client && npm install RUN cd client && npm install
COPY client/ ./client/ COPY client/ ./client/
RUN cd client && npm run build RUN cd client && npm run build
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2: Production image
# ─────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS production FROM node:20-alpine AS production
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
# Chromium for Puppeteer PDF generation
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3001 ENV PORT=3001
ENV DB_PATH=/data/cpas.db ENV DB_PATH=/data/cpas.db
WORKDIR /app WORKDIR /app
# Copy backend node_modules and compiled frontend from builder
COPY --from=builder /build/node_modules ./node_modules COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/client/dist ./client/dist COPY --from=builder /build/client/dist ./client/dist
# Copy all backend source files
COPY server.js ./ COPY server.js ./
COPY package.json ./ COPY package.json ./
COPY db/ ./db/ COPY db/ ./db/
COPY pdf/ ./pdf/ COPY pdf/ ./pdf/
COPY client/public/static ./client/dist/static
# Ensure data directory exists
RUN mkdir -p /data RUN mkdir -p /data
EXPOSE 3001 EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3001/api/health || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -8,16 +8,19 @@ const tabs = [
]; ];
const s = { const s = {
app: { minHeight: '100vh', background: '#f5f6fa', fontFamily: "'Segoe UI', Arial, sans-serif" }, app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa' },
nav: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0 }, nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' },
logo: { color: 'white', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px', marginRight: '32px', padding: '18px 0' }, logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' },
logoImg: { height: '28px', marginRight: '10px' },
logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' },
tab: (active) => ({ tab: (active) => ({
padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)', padding: '18px 22px',
borderBottom: active ? '3px solid #667eea' : '3px solid transparent', color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)',
borderBottom: active ? '3px solid #d4af37' : '3px solid transparent',
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
background: 'none', border: 'none', borderBottom: active ? '3px solid #667eea' : '3px solid transparent', background: 'none', border: 'none',
}), }),
card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }, card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
}; };
export default function App() { export default function App() {
@@ -25,7 +28,10 @@ export default function App() {
return ( return (
<div style={s.app}> <div style={s.app}>
<nav style={s.nav}> <nav style={s.nav}>
<div style={s.logo}>CPAS Tracker</div> <div style={s.logoWrap}>
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
<div style={s.logoText}>CPAS Tracker</div>
</div>
{tabs.map(t => ( {tabs.map(t => (
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}> <button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
{t.label} {t.label}

View File

@@ -3,7 +3,7 @@ import axios from 'axios';
import CpasBadge, { getTier } from './CpasBadge'; import CpasBadge, { getTier } from './CpasBadge';
import EmployeeModal from './EmployeeModal'; import EmployeeModal from './EmployeeModal';
const AT_RISK_THRESHOLD = 2; // points within next tier boundary const AT_RISK_THRESHOLD = 2;
const TIERS = [ const TIERS = [
{ min: 0, max: 4 }, { min: 0, max: 4 },
@@ -17,8 +17,7 @@ const TIERS = [
function nextTierBoundary(points) { function nextTierBoundary(points) {
for (const t of TIERS) { for (const t of TIERS) {
if (points >= t.min && points <= t.max && t.max < 999) if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
return t.max + 1;
} }
return null; return null;
} }
@@ -29,29 +28,29 @@ function isAtRisk(points) {
} }
const s = { const s = {
wrap: { padding: '40px' }, wrap: { padding: '32px 40px', color: '#f8f9fa' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
title: { fontSize: '24px', fontWeight: 700, color: '#2c3e50' }, title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' },
subtitle: { fontSize: '13px', color: '#888', marginTop: '3px' }, subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' },
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' }, statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
statCard: { flex: '1', minWidth: '140px', background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: '8px', padding: '16px', textAlign: 'center' }, statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #30313f', borderRadius: '8px', padding: '16px', textAlign: 'center' },
statNum: { fontSize: '28px', fontWeight: 800, color: '#2c3e50' }, statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
statLbl: { fontSize: '11px', color: '#888', marginTop: '4px' }, statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
search: { padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', width: '260px' }, search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' },
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }, table: { width: '100%', borderCollapse: 'collapse', background: '#111217', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 8px rgba(0,0,0,0.6)', border: '1px solid #222' },
th: { background: '#34495e', color: 'white', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }, th: { background: '#000000', color: '#f8f9fa', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
td: { padding: '11px 14px', borderBottom: '1px solid #f0f0f0', fontSize: '13px', verticalAlign: 'middle' }, td: { padding: '11px 14px', borderBottom: '1px solid #1c1d29', fontSize: '13px', verticalAlign: 'middle', color: '#f8f9fa' },
nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#667eea', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' }, nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#d4af37', fontSize: '14px', padding: 0, textDecoration: 'underline dotted' },
atRiskBadge: { display: 'inline-block', marginLeft: '8px', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#fff3cd', color: '#856404', border: '1px solid #ffc107', verticalAlign: 'middle' }, atRiskBadge: { display: 'inline-block', marginLeft: '8px', padding: '2px 8px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37', verticalAlign: 'middle' },
zeroRow: { color: '#aaa', fontStyle: 'italic', fontSize: '12px' }, zeroRow: { color: '#77798a', fontStyle: 'italic', fontSize: '12px' },
refreshBtn:{ padding: '9px 18px', background: '#667eea', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }, refreshBtn:{ padding: '9px 18px', background: '#d4af37', color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
}; };
export default function Dashboard() { export default function Dashboard() {
const [employees, setEmployees] = useState([]); const [employees, setEmployees] = useState([]);
const [filtered, setFiltered] = useState([]); const [filtered, setFiltered] = useState([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null); const [selectedId,setSelectedId] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(() => { const load = useCallback(() => {
@@ -90,33 +89,31 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* ── Stat cards ───────────────────────────────────────── */}
<div style={s.statsRow}> <div style={s.statsRow}>
<div style={s.statCard}> <div style={s.statCard}>
<div style={s.statNum}>{employees.length}</div> <div style={s.statNum}>{employees.length}</div>
<div style={s.statLbl}>Total Employees</div> <div style={s.statLbl}>Total Employees</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}> <div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
<div style={{ ...s.statNum, color: '#28a745' }}>{cleanCount}</div> <div style={{ ...s.statNum, color: '#6ee7b7' }}>{cleanCount}</div>
<div style={s.statLbl}>Elite Standing (0 pts)</div> <div style={s.statLbl}>Elite Standing (0 pts)</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #856404' }}> <div style={{ ...s.statCard, borderTop: '3px solid #d4af37' }}>
<div style={{ ...s.statNum, color: '#856404' }}>{activeCount}</div> <div style={{ ...s.statNum, color: '#ffd666' }}>{activeCount}</div>
<div style={s.statLbl}>With Active Points</div> <div style={s.statLbl}>With Active Points</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #ffc107' }}> <div style={{ ...s.statCard, borderTop: '3px solid #ffb020' }}>
<div style={{ ...s.statNum, color: '#856404' }}>{atRiskCount}</div> <div style={{ ...s.statNum, color: '#ffdf8a' }}>{atRiskCount}</div>
<div style={s.statLbl}>At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div> <div style={s.statLbl}>At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}> <div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
<div style={{ ...s.statNum, color: '#c0392b' }}>{maxPoints}</div> <div style={{ ...s.statNum, color: '#ff8a80' }}>{maxPoints}</div>
<div style={s.statLbl}>Highest Active Score</div> <div style={s.statLbl}>Highest Active Score</div>
</div> </div>
</div> </div>
{/* ── Scoreboard table ─────────────────────────────────── */}
{loading ? ( {loading ? (
<p style={{ color: '#aaa', textAlign: 'center', padding: '40px' }}>Loading</p> <p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading</p>
) : ( ) : (
<table style={s.table}> <table style={s.table}>
<thead> <thead>
@@ -139,8 +136,8 @@ export default function Dashboard() {
const tier = getTier(emp.active_points); const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points); const boundary = nextTierBoundary(emp.active_points);
return ( return (
<tr key={emp.id} style={{ background: risk ? '#fffdf0' : i % 2 === 0 ? 'white' : '#fafafa' }}> <tr key={emp.id} style={{ background: risk ? '#181200' : i % 2 === 0 ? '#111217' : '#151622' }}>
<td style={{ ...s.td, color: '#aaa', fontSize: '12px' }}>{i + 1}</td> <td style={{ ...s.td, color: '#77798a', fontSize: '12px' }}>{i + 1}</td>
<td style={s.td}> <td style={s.td}>
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>{emp.name}</button> <button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>{emp.name}</button>
{risk && ( {risk && (
@@ -149,11 +146,11 @@ export default function Dashboard() {
</span> </span>
)} )}
</td> </td>
<td style={{ ...s.td, color: '#666' }}>{emp.department || '—'}</td> <td style={{ ...s.td, color: '#c0c2d6' }}>{emp.department || '—'}</td>
<td style={{ ...s.td, color: '#666' }}>{emp.supervisor || '—'}</td> <td style={{ ...s.td, color: '#c0c2d6' }}>{emp.supervisor || '—'}</td>
<td style={s.td}><CpasBadge points={emp.active_points} /></td> <td style={s.td}><CpasBadge points={emp.active_points} /></td>
<td style={{ ...s.td, fontWeight: 700, color: tier.color, fontSize: '16px' }}>{emp.active_points}</td> <td style={{ ...s.td, fontWeight: 700, color: tier.color, fontSize: '16px' }}>{emp.active_points}</td>
<td style={{ ...s.td, color: '#666' }}>{emp.violation_count}</td> <td style={{ ...s.td, color: '#c0c2d6' }}>{emp.violation_count}</td>
</tr> </tr>
); );
})} })}
@@ -161,7 +158,6 @@ export default function Dashboard() {
</table> </table>
)} )}
{/* ── Employee profile modal ───────────────────────────── */}
{selectedId && ( {selectedId && (
<EmployeeModal <EmployeeModal
employeeId={selectedId} employeeId={selectedId}

View File

@@ -1,7 +1,4 @@
/** /** PDF template with MPM logo from /static/mpm-logo.png */
* Builds the full HTML string for a CPAS violation PDF document.
* Matches the styling of the original HTML violation form.
*/
const TIERS = [ const TIERS = [
{ min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745' }, { min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745' },
@@ -59,16 +56,28 @@ function buildHtml(v, score) {
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #222; background: #fff; } 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 {
.header h1 { font-size: 22px; letter-spacing: 0.5px; } background: linear-gradient(135deg, #000000, #111217);
.header p { font-size: 12px; opacity: 0.8; margin-top: 4px; } color: white;
padding: 22px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; }
.logo {
height: 28px;
margin-right: 12px;
}
.header h1 { font-size: 20px; letter-spacing: 0.5px; }
.header p { font-size: 11px; opacity: 0.85; margin-top: 3px; }
.doc-id { float: right; text-align: right; font-size: 11px; opacity: 0.75; } .doc-id { text-align: right; font-size: 11px; opacity: 0.8; }
.section { margin: 20px 0; } .section { margin: 20px 0; }
.section-title { .section-title {
font-size: 14px; font-weight: 700; color: white; font-size: 14px; font-weight: 700; color: white;
background: #34495e; padding: 8px 14px; background: #000000; padding: 8px 14px;
border-radius: 4px 4px 0 0; margin-bottom: 0; border-radius: 4px 4px 0 0; margin-bottom: 0;
} }
table { width: 100%; border-collapse: collapse; border: 1px solid #ddd; border-top: none; } table { width: 100%; border-collapse: collapse; border: 1px solid #ddd; border-top: none; }
@@ -97,7 +106,7 @@ function buildHtml(v, score) {
background: #fff3cd; border: 2px solid #ffc107; background: #fff3cd; border: 2px solid #ffc107;
border-radius: 6px; padding: 16px; margin: 16px 0; text-align: center; border-radius: 6px; padding: 16px; margin: 16px 0; text-align: center;
} }
.points-display .pts { font-size: 36px; font-weight: 800; color: #667eea; } .points-display .pts { font-size: 36px; font-weight: 800; color: #d4af37; }
.points-display .lbl { font-size: 12px; color: #666; } .points-display .lbl { font-size: 12px; color: #666; }
.sig-section { margin-top: 40px; page-break-inside: avoid; } .sig-section { margin-top: 40px; page-break-inside: avoid; }
@@ -108,7 +117,7 @@ function buildHtml(v, score) {
.footer-bar { .footer-bar {
margin-top: 40px; padding: 10px 0; margin-top: 40px; padding: 10px 0;
border-top: 2px solid #2c3e50; border-top: 2px solid #000000;
font-size: 10px; color: #888; text-align: center; font-size: 10px; color: #888; text-align: center;
} }
.confidential { .confidential {
@@ -130,14 +139,18 @@ function buildHtml(v, score) {
</head> </head>
<body> <body>
<!-- Header -->
<div class="header"> <div class="header">
<div class="header-left">
<img src="/static/mpm-logo.png" class="logo" />
<div>
<h1>CPAS Individual Violation Record</h1>
<p>Message Point Media — Comprehensive Professional Accountability System</p>
</div>
</div>
<div class="doc-id"> <div class="doc-id">
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br /> Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
Generated: ${generatedAt} Generated: ${generatedAt}
</div> </div>
<h1>CPAS Individual Violation Record</h1>
<p>Message Point Media — Confidential HR Document</p>
</div> </div>
<div style="padding: 0 4px;"> <div style="padding: 0 4px;">
@@ -146,7 +159,6 @@ function buildHtml(v, score) {
⚠ CONFIDENTIAL — For authorized HR and management use only ⚠ CONFIDENTIAL — For authorized HR and management use only
</div> </div>
<!-- Employee Information -->
<div class="section"> <div class="section">
<div class="section-title">Employee Information</div> <div class="section-title">Employee Information</div>
<table> <table>
@@ -157,7 +169,6 @@ function buildHtml(v, score) {
</table> </table>
</div> </div>
<!-- Violation Details -->
<div class="section"> <div class="section">
<div class="section-title">Violation Details</div> <div class="section-title">Violation Details</div>
<table> <table>
@@ -168,7 +179,6 @@ function buildHtml(v, score) {
${v.location ? row('Location / Context', v.location) : ''} ${v.location ? row('Location / Context', v.location) : ''}
${row('Submitted By', v.submitted_by || 'System')} ${row('Submitted By', v.submitted_by || 'System')}
</table> </table>
${v.details ? ` ${v.details ? `
<div class="policy-context"> <div class="policy-context">
<strong>Incident Details:</strong><br /> <strong>Incident Details:</strong><br />
@@ -176,15 +186,12 @@ function buildHtml(v, score) {
</div>` : ''} </div>` : ''}
</div> </div>
<!-- CPAS Point Assessment -->
<div class="section"> <div class="section">
<div class="section-title">CPAS Point Assessment</div> <div class="section-title">CPAS Point Assessment</div>
<div class="points-display"> <div class="points-display">
<div class="pts">${v.points}</div> <div class="pts">${v.points}</div>
<div class="lbl">Points Assessed — This Violation</div> <div class="lbl">Points Assessed — This Violation</div>
</div> </div>
<div class="score-box"> <div class="score-box">
<div class="score-cell"> <div class="score-cell">
<div class="score-num" style="color:${tier.color};">${activePts}</div> <div class="score-num" style="color:${tier.color};">${activePts}</div>
@@ -195,7 +202,7 @@ function buildHtml(v, score) {
</div> </div>
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">+</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-cell">
<div class="score-num" style="color:#667eea;">${v.points}</div> <div class="score-num" style="color:#d4af37;">${v.points}</div>
<div class="score-lbl">Points — This Violation</div> <div class="score-lbl">Points — This Violation</div>
</div> </div>
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">=</div> <div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">=</div>
@@ -207,16 +214,13 @@ function buildHtml(v, score) {
</div> </div>
</div> </div>
</div> </div>
${tierChange ? ` ${tierChange ? `
<div class="tier-change"> <div class="tier-change">
<strong>⚠ Tier Escalation:</strong> This violation advances the employee from <strong>⚠ Tier Escalation:</strong> This violation advances the employee from
<strong>${tier.label}</strong> to <strong>${newTier.label}</strong>. <strong>${tier.label}</strong> to <strong>${newTier.label}</strong>.
Review associated tier consequences per the Employee Handbook.
</div>` : ''} </div>` : ''}
</div> </div>
<!-- CPAS Tier Reference -->
<div class="section"> <div class="section">
<div class="section-title">CPAS Tier Reference</div> <div class="section-title">CPAS Tier Reference</div>
<table> <table>
@@ -232,16 +236,13 @@ function buildHtml(v, score) {
</table> </table>
</div> </div>
<!-- Notice -->
<div class="notice"> <div class="notice">
<strong>Employee Notice:</strong> CPAS points remain active for a rolling 90-day period from the date of each incident. <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, 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> </div>
<!-- Signatures — EXPANDED VERTICAL SPACING -->
<div class="sig-section"> <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 class="section-title" style="background:#000000;">Acknowledgement & Signatures</div>
<div style="padding: 16px 0;"> <div style="padding: 16px 0;">
<p style="font-size:12px; color:#555; margin-bottom:28px; line-height:1.6;"> <p style="font-size:12px; color:#555; margin-bottom:28px; line-height:1.6;">
By signing below, the employee acknowledges receipt of this violation record. By signing below, the employee acknowledges receipt of this violation record.
@@ -250,20 +251,12 @@ function buildHtml(v, score) {
</p> </p>
<div class="sig-grid"> <div class="sig-grid">
<div> <div>
<div class="sig-block"> <div class="sig-block"><div class="sig-label">Employee Signature</div></div>
<div class="sig-label">Employee Signature</div> <div class="sig-date-block"><div class="sig-label">Date</div></div>
</div>
<div class="sig-date-block">
<div class="sig-label">Date</div>
</div>
</div> </div>
<div> <div>
<div class="sig-block"> <div class="sig-block"><div class="sig-label">Supervisor / Documenting Officer Signature</div></div>
<div class="sig-label">Supervisor / Documenting Officer Signature</div> <div class="sig-date-block"><div class="sig-label">Date</div></div>
</div>
<div class="sig-date-block">
<div class="sig-label">Date</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -275,7 +268,7 @@ function buildHtml(v, score) {
Message Point Media Internal Use Only Message Point Media Internal Use Only
</div> </div>
</div><!-- /padding --> </div>
</body> </body>
</html>`; </html>`;
} }