p4-hotfixes #10

Merged
jason merged 4 commits from p4-hotfixes into master 2026-03-06 14:13:15 -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
WORKDIR /build
# Install backend deps
COPY package.json ./
RUN npm install
# Install frontend deps and build React app
COPY client/package.json ./client/
RUN cd client && npm install
COPY client/ ./client/
RUN cd client && npm run build
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2: Production image
# ─────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS production
# Chromium for Puppeteer PDF generation
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
ENV PORT=3001
ENV DB_PATH=/data/cpas.db
WORKDIR /app
# Copy backend node_modules and compiled frontend from builder
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/client/dist ./client/dist
# Copy all backend source files
COPY server.js ./
COPY package.json ./
COPY db/ ./db/
COPY pdf/ ./pdf/
# Ensure data directory exists
COPY client/public/static ./client/dist/static
RUN mkdir -p /data
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"]

View File

@@ -3,38 +3,44 @@ import ViolationForm from './components/ViolationForm';
import Dashboard from './components/Dashboard';
const tabs = [
{ id: 'dashboard', label: '📊 Dashboard' },
{ id: 'violation', label: '+ New Violation' },
{ id: 'dashboard', label: '📊 Dashboard' },
{ id: 'violation', label: '+ New Violation' },
];
const s = {
app: { minHeight: '100vh', background: '#f5f6fa', fontFamily: "'Segoe UI', Arial, sans-serif" },
nav: { background: 'linear-gradient(135deg, #2c3e50, #34495e)', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0 },
logo: { color: 'white', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px', marginRight: '32px', padding: '18px 0' },
tab: (active) => ({
padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)',
borderBottom: active ? '3px solid #667eea' : '3px solid transparent',
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
background: 'none', border: 'none', borderBottom: active ? '3px solid #667eea' : '3px solid transparent',
}),
card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' },
app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa' },
nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' },
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) => ({
padding: '18px 22px',
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',
background: 'none', border: 'none',
}),
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() {
const [tab, setTab] = useState('dashboard');
return (
<div style={s.app}>
<nav style={s.nav}>
<div style={s.logo}>CPAS Tracker</div>
{tabs.map(t => (
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
</nav>
<div style={s.card}>
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div>
const [tab, setTab] = useState('dashboard');
return (
<div style={s.app}>
<nav style={s.nav}>
<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 => (
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
</nav>
<div style={s.card}>
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div>
</div>
);
}

View File

@@ -3,171 +3,167 @@ import axios from 'axios';
import CpasBadge, { getTier } from './CpasBadge';
import EmployeeModal from './EmployeeModal';
const AT_RISK_THRESHOLD = 2; // points within next tier boundary
const AT_RISK_THRESHOLD = 2;
const TIERS = [
{ min: 0, max: 4 },
{ min: 5, max: 9 },
{ min: 10, max: 14 },
{ min: 15, max: 19 },
{ min: 20, max: 24 },
{ min: 25, max: 29 },
{ min: 30, max: 999},
{ min: 0, max: 4 },
{ min: 5, max: 9 },
{ min: 10, max: 14 },
{ min: 15, max: 19 },
{ min: 20, max: 24 },
{ min: 25, max: 29 },
{ min: 30, max: 999},
];
function nextTierBoundary(points) {
for (const t of TIERS) {
if (points >= t.min && points <= t.max && t.max < 999)
return t.max + 1;
}
return null;
for (const t of TIERS) {
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
}
return null;
}
function isAtRisk(points) {
const boundary = nextTierBoundary(points);
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
const boundary = nextTierBoundary(points);
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
}
const s = {
wrap: { padding: '40px' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
title: { fontSize: '24px', fontWeight: 700, color: '#2c3e50' },
subtitle: { fontSize: '13px', color: '#888', marginTop: '3px' },
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' },
statNum: { fontSize: '28px', fontWeight: 800, color: '#2c3e50' },
statLbl: { fontSize: '11px', color: '#888', marginTop: '4px' },
search: { padding: '10px 14px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', width: '260px' },
table: { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' },
th: { background: '#34495e', color: 'white', 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' },
nameBtn: { background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, color: '#667eea', 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' },
zeroRow: { color: '#aaa', fontStyle: 'italic', fontSize: '12px' },
refreshBtn:{ padding: '9px 18px', background: '#667eea', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
wrap: { padding: '32px 40px', color: '#f8f9fa' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' },
subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' },
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #30313f', borderRadius: '8px', padding: '16px', textAlign: 'center' },
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' },
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: '#000000', color: '#f8f9fa', padding: '10px 14px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
td: { padding: '11px 14px', borderBottom: '1px solid #1c1d29', fontSize: '13px', verticalAlign: 'middle', color: '#f8f9fa' },
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: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37', verticalAlign: 'middle' },
zeroRow: { color: '#77798a', fontStyle: 'italic', fontSize: '12px' },
refreshBtn:{ padding: '9px 18px', background: '#d4af37', color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
};
export default function Dashboard() {
const [employees, setEmployees] = useState([]);
const [filtered, setFiltered] = useState([]);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [loading, setLoading] = useState(true);
const [employees, setEmployees] = useState([]);
const [filtered, setFiltered] = useState([]);
const [search, setSearch] = useState('');
const [selectedId,setSelectedId] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
axios.get('/api/dashboard')
.then(r => { setEmployees(r.data); setFiltered(r.data); })
.finally(() => setLoading(false));
}, []);
const load = useCallback(() => {
setLoading(true);
axios.get('/api/dashboard')
.then(r => { setEmployees(r.data); setFiltered(r.data); })
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
const q = search.toLowerCase();
setFiltered(employees.filter(e =>
e.name.toLowerCase().includes(q) ||
(e.department || '').toLowerCase().includes(q) ||
(e.supervisor || '').toLowerCase().includes(q)
));
}, [search, employees]);
useEffect(() => {
const q = search.toLowerCase();
setFiltered(employees.filter(e =>
e.name.toLowerCase().includes(q) ||
(e.department || '').toLowerCase().includes(q) ||
(e.supervisor || '').toLowerCase().includes(q)
));
}, [search, employees]);
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
const activeCount = employees.filter(e => e.active_points > 0).length;
const cleanCount = employees.filter(e => e.active_points === 0).length;
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
const activeCount = employees.filter(e => e.active_points > 0).length;
const cleanCount = employees.filter(e => e.active_points === 0).length;
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
return (
<div style={s.wrap}>
<div style={s.header}>
<div>
<div style={s.title}>Company Dashboard</div>
<div style={s.subtitle}>Click any employee name to view their full profile</div>
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input style={s.search} placeholder="Search name, dept, supervisor…" value={search} onChange={e => setSearch(e.target.value)} />
<button style={s.refreshBtn} onClick={load}> Refresh</button>
</div>
</div>
{/* ── Stat cards ───────────────────────────────────────── */}
<div style={s.statsRow}>
<div style={s.statCard}>
<div style={s.statNum}>{employees.length}</div>
<div style={s.statLbl}>Total Employees</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
<div style={{ ...s.statNum, color: '#28a745' }}>{cleanCount}</div>
<div style={s.statLbl}>Elite Standing (0 pts)</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #856404' }}>
<div style={{ ...s.statNum, color: '#856404' }}>{activeCount}</div>
<div style={s.statLbl}>With Active Points</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #ffc107' }}>
<div style={{ ...s.statNum, color: '#856404' }}>{atRiskCount}</div>
<div style={s.statLbl}>At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
<div style={{ ...s.statNum, color: '#c0392b' }}>{maxPoints}</div>
<div style={s.statLbl}>Highest Active Score</div>
</div>
</div>
{/* ── Scoreboard table ─────────────────────────────────── */}
{loading ? (
<p style={{ color: '#aaa', textAlign: 'center', padding: '40px' }}>Loading</p>
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>#</th>
<th style={s.th}>Employee</th>
<th style={s.th}>Department</th>
<th style={s.th}>Supervisor</th>
<th style={s.th}>Tier / Standing</th>
<th style={s.th}>Active Points</th>
<th style={s.th}>90-Day Violations</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ ...s.td, textAlign: 'center', ...s.zeroRow }}>No employees found.</td></tr>
)}
{filtered.map((emp, i) => {
const risk = isAtRisk(emp.active_points);
const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points);
return (
<tr key={emp.id} style={{ background: risk ? '#fffdf0' : i % 2 === 0 ? 'white' : '#fafafa' }}>
<td style={{ ...s.td, color: '#aaa', fontSize: '12px' }}>{i + 1}</td>
<td style={s.td}>
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>{emp.name}</button>
{risk && (
<span style={s.atRiskBadge}>
{boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
</span>
)}
</td>
<td style={{ ...s.td, color: '#666' }}>{emp.department || '—'}</td>
<td style={{ ...s.td, color: '#666' }}>{emp.supervisor || '—'}</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, color: '#666' }}>{emp.violation_count}</td>
</tr>
);
})}
</tbody>
</table>
)}
{/* ── Employee profile modal ───────────────────────────── */}
{selectedId && (
<EmployeeModal
employeeId={selectedId}
onClose={() => { setSelectedId(null); load(); }}
/>
)}
return (
<div style={s.wrap}>
<div style={s.header}>
<div>
<div style={s.title}>Company Dashboard</div>
<div style={s.subtitle}>Click any employee name to view their full profile</div>
</div>
);
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input style={s.search} placeholder="Search name, dept, supervisor…" value={search} onChange={e => setSearch(e.target.value)} />
<button style={s.refreshBtn} onClick={load}> Refresh</button>
</div>
</div>
<div style={s.statsRow}>
<div style={s.statCard}>
<div style={s.statNum}>{employees.length}</div>
<div style={s.statLbl}>Total Employees</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}>
<div style={{ ...s.statNum, color: '#6ee7b7' }}>{cleanCount}</div>
<div style={s.statLbl}>Elite Standing (0 pts)</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #d4af37' }}>
<div style={{ ...s.statNum, color: '#ffd666' }}>{activeCount}</div>
<div style={s.statLbl}>With Active Points</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #ffb020' }}>
<div style={{ ...s.statNum, color: '#ffdf8a' }}>{atRiskCount}</div>
<div style={s.statLbl}>At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div>
</div>
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}>
<div style={{ ...s.statNum, color: '#ff8a80' }}>{maxPoints}</div>
<div style={s.statLbl}>Highest Active Score</div>
</div>
</div>
{loading ? (
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading</p>
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>#</th>
<th style={s.th}>Employee</th>
<th style={s.th}>Department</th>
<th style={s.th}>Supervisor</th>
<th style={s.th}>Tier / Standing</th>
<th style={s.th}>Active Points</th>
<th style={s.th}>90-Day Violations</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ ...s.td, textAlign: 'center', ...s.zeroRow }}>No employees found.</td></tr>
)}
{filtered.map((emp, i) => {
const risk = isAtRisk(emp.active_points);
const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points);
return (
<tr key={emp.id} style={{ background: risk ? '#181200' : i % 2 === 0 ? '#111217' : '#151622' }}>
<td style={{ ...s.td, color: '#77798a', fontSize: '12px' }}>{i + 1}</td>
<td style={s.td}>
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>{emp.name}</button>
{risk && (
<span style={s.atRiskBadge}>
{boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
</span>
)}
</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.department || '—'}</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.supervisor || '—'}</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, color: '#c0c2d6' }}>{emp.violation_count}</td>
</tr>
);
})}
</tbody>
</table>
)}
{selectedId && (
<EmployeeModal
employeeId={selectedId}
onClose={() => { setSelectedId(null); load(); }}
/>
)}
</div>
);
}

View File

@@ -1,281 +1,274 @@
/**
* Builds the full HTML string for a CPAS violation PDF document.
* Matches the styling of the original HTML violation form.
*/
/** PDF template with MPM logo from /static/mpm-logo.png */
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' },
{ 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];
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'
});
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;
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>`;
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 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'
});
const generatedAt = new Date().toLocaleString('en-US', {
timeZone: 'America/Chicago',
dateStyle: 'full', timeStyle: 'short'
});
return `<!DOCTYPE html>
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; }
* { 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; }
.header {
background: linear-gradient(135deg, #000000, #111217);
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-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; }
.section { margin: 20px 0; }
.section-title {
font-size: 14px; font-weight: 700; color: white;
background: #000000; 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; }
.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;
}
.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; }
.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: #d4af37; }
.points-display .lbl { font-size: 12px; color: #666; }
.sig-section { margin-top: 40px; page-break-inside: avoid; }
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 24px; }
.sig-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 60px; }
.sig-label { font-size: 11px; color: #555; font-weight: 600; }
.sig-date-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 50px; margin-top: 32px; }
.sig-section { margin-top: 40px; page-break-inside: avoid; }
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 24px; }
.sig-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 60px; }
.sig-label { font-size: 11px; color: #555; font-weight: 600; }
.sig-date-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 50px; margin-top: 32px; }
.footer-bar {
margin-top: 40px; 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;
}
.policy-context {
background: #f8f9fa; border-left: 3px solid #667eea;
padding: 12px 16px; margin: 12px 0; font-size: 12px; color: #444;
border-radius: 4px;
}
.footer-bar {
margin-top: 40px; padding: 10px 0;
border-top: 2px solid #000000;
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;
}
.policy-context {
background: #f8f9fa; border-left: 3px solid #667eea;
padding: 12px 16px; margin: 12px 0; font-size: 12px; color: #444;
border-radius: 4px;
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="doc-id">
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
Generated: ${generatedAt}
<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>
<h1>CPAS Individual Violation Record</h1>
<p>Message Point Media — Confidential HR Document</p>
</div>
<div class="doc-id">
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
Generated: ${generatedAt}
</div>
</div>
<div style="padding: 0 4px;">
<div class="confidential" style="margin-top:16px;">
⚠ CONFIDENTIAL — For authorized HR and management use only
⚠ 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 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', 'Chapter 4, Section 5 — Comprehensive Professional Accountability System (CPAS)')}
${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))}
${v.location ? row('Location / Context', v.location) : ''}
${row('Submitted By', v.submitted_by || 'System')}
</table>
${v.details ? `
<div class="section-title">Violation Details</div>
<table>
${row('Violation Type', `<strong>${v.violation_name}</strong>`)}
${row('Category', v.category)}
${row('Policy Reference', 'Chapter 4, Section 5 — Comprehensive Professional Accountability System (CPAS)')}
${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))}
${v.location ? row('Location / Context', v.location) : ''}
${row('Submitted By', v.submitted_by || 'System')}
</table>
${v.details ? `
<div class="policy-context">
<strong>Incident Details:</strong><br />
${v.details}
<strong>Incident Details:</strong><br />
${v.details}
</div>` : ''}
</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 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-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 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:#d4af37;">${v.points}</div>
<div class="score-lbl">Points — This Violation</div>
</div>
${tierChange ? `
<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.
<strong>⚠ Tier Escalation:</strong> This violation advances the employee from
<strong>${tier.label}</strong> to <strong>${newTier.label}</strong>.
</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 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.
<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.
</div>
<!-- Signatures — EXPANDED VERTICAL SPACING -->
<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: 16px 0;">
<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.
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 class="sig-date-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 class="sig-date-block">
<div class="sig-label">Date</div>
</div>
</div>
</div>
<div class="section-title" style="background:#000000;">Acknowledgement & Signatures</div>
<div style="padding: 16px 0;">
<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.
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 class="sig-date-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 class="sig-date-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
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 -->
</div>
</body>
</html>`;
}