p4-hotfixes #10
39
Dockerfile
39
Dockerfile
@@ -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"]
|
||||||
|
|||||||
@@ -3,38 +3,44 @@ import ViolationForm from './components/ViolationForm';
|
|||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'dashboard', label: '📊 Dashboard' },
|
{ id: 'dashboard', label: '📊 Dashboard' },
|
||||||
{ id: 'violation', label: '+ New Violation' },
|
{ id: 'violation', label: '+ New Violation' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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' },
|
||||||
tab: (active) => ({
|
logoImg: { height: '28px', marginRight: '10px' },
|
||||||
padding: '18px 22px', color: active ? 'white' : 'rgba(255,255,255,0.6)',
|
logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' },
|
||||||
borderBottom: active ? '3px solid #667eea' : '3px solid transparent',
|
tab: (active) => ({
|
||||||
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
padding: '18px 22px',
|
||||||
background: 'none', border: 'none', 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',
|
||||||
card: { maxWidth: '1100px', margin: '30px auto', background: 'white', borderRadius: '10px', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' },
|
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() {
|
export default function App() {
|
||||||
const [tab, setTab] = useState('dashboard');
|
const [tab, setTab] = useState('dashboard');
|
||||||
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}>
|
||||||
{tabs.map(t => (
|
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} />
|
||||||
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}>
|
<div style={s.logoText}>CPAS Tracker</div>
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div style={s.card}>
|
|
||||||
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,171 +3,167 @@ 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 },
|
||||||
{ min: 5, max: 9 },
|
{ min: 5, max: 9 },
|
||||||
{ min: 10, max: 14 },
|
{ min: 10, max: 14 },
|
||||||
{ min: 15, max: 19 },
|
{ min: 15, max: 19 },
|
||||||
{ min: 20, max: 24 },
|
{ min: 20, max: 24 },
|
||||||
{ min: 25, max: 29 },
|
{ min: 25, max: 29 },
|
||||||
{ min: 30, max: 999},
|
{ min: 30, max: 999},
|
||||||
];
|
];
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAtRisk(points) {
|
function isAtRisk(points) {
|
||||||
const boundary = nextTierBoundary(points);
|
const boundary = nextTierBoundary(points);
|
||||||
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios.get('/api/dashboard')
|
axios.get('/api/dashboard')
|
||||||
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
.then(r => { setEmployees(r.data); setFiltered(r.data); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
setFiltered(employees.filter(e =>
|
setFiltered(employees.filter(e =>
|
||||||
e.name.toLowerCase().includes(q) ||
|
e.name.toLowerCase().includes(q) ||
|
||||||
(e.department || '').toLowerCase().includes(q) ||
|
(e.department || '').toLowerCase().includes(q) ||
|
||||||
(e.supervisor || '').toLowerCase().includes(q)
|
(e.supervisor || '').toLowerCase().includes(q)
|
||||||
));
|
));
|
||||||
}, [search, employees]);
|
}, [search, employees]);
|
||||||
|
|
||||||
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
|
||||||
const activeCount = employees.filter(e => e.active_points > 0).length;
|
const activeCount = employees.filter(e => e.active_points > 0).length;
|
||||||
const cleanCount = 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 maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.wrap}>
|
<div style={s.wrap}>
|
||||||
<div style={s.header}>
|
<div style={s.header}>
|
||||||
<div>
|
<div>
|
||||||
<div style={s.title}>Company Dashboard</div>
|
<div style={s.title}>Company Dashboard</div>
|
||||||
<div style={s.subtitle}>Click any employee name to view their full profile</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(); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
405
pdf/template.js
405
pdf/template.js
@@ -1,281 +1,274 @@
|
|||||||
/**
|
/** 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' },
|
||||||
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404' },
|
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404' },
|
||||||
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f' },
|
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f' },
|
||||||
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f' },
|
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f' },
|
||||||
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#c0392b' },
|
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#c0392b' },
|
||||||
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#c0392b' },
|
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#c0392b' },
|
||||||
{ min: 30, max: 999,label: 'Tier 6 — Separation', color: '#721c24' },
|
{ min: 30, max: 999,label: 'Tier 6 — Separation', color: '#721c24' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getTier(points) {
|
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) {
|
function formatDate(d) {
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
const dt = new Date(d + 'T12:00:00');
|
const dt = new Date(d + 'T12:00:00');
|
||||||
return dt.toLocaleDateString('en-US', {
|
return dt.toLocaleDateString('en-US', {
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
timeZone: 'America/Chicago'
|
timeZone: 'America/Chicago'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(d, t) {
|
function formatDateTime(d, t) {
|
||||||
const date = formatDate(d);
|
const date = formatDate(d);
|
||||||
return t ? `${date} at ${t}` : date;
|
return t ? `${date} at ${t}` : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function row(label, value) {
|
function row(label, value) {
|
||||||
return `
|
return `
|
||||||
<tr>
|
<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="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>
|
<td style="padding:8px 12px; border-bottom:1px solid #eee; color:#222;">${value || '—'}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHtml(v, score) {
|
function buildHtml(v, score) {
|
||||||
const activePts = score.active_points || 0;
|
const activePts = score.active_points || 0;
|
||||||
const tier = getTier(activePts);
|
const tier = getTier(activePts);
|
||||||
const newTotal = activePts + v.points;
|
const newTotal = activePts + v.points;
|
||||||
const newTier = getTier(newTotal);
|
const newTier = getTier(newTotal);
|
||||||
const tierChange = tier.label !== newTier.label;
|
const tierChange = tier.label !== newTier.label;
|
||||||
|
|
||||||
const generatedAt = new Date().toLocaleString('en-US', {
|
const generatedAt = new Date().toLocaleString('en-US', {
|
||||||
timeZone: 'America/Chicago',
|
timeZone: 'America/Chicago',
|
||||||
dateStyle: 'full', timeStyle: 'short'
|
dateStyle: 'full', timeStyle: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<style>
|
<style>
|
||||||
* { 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; }
|
||||||
|
|
||||||
.score-box {
|
.score-box {
|
||||||
display: flex; gap: 20px; flex-wrap: wrap;
|
display: flex; gap: 20px; flex-wrap: wrap;
|
||||||
background: #f8f9fa; border: 1px solid #ddd;
|
background: #f8f9fa; border: 1px solid #ddd;
|
||||||
border-radius: 6px; padding: 16px 20px; margin: 20px 0;
|
border-radius: 6px; padding: 16px 20px; margin: 20px 0;
|
||||||
}
|
}
|
||||||
.score-cell { flex: 1; min-width: 120px; text-align: center; }
|
.score-cell { flex: 1; min-width: 120px; text-align: center; }
|
||||||
.score-num { font-size: 28px; font-weight: 800; }
|
.score-num { font-size: 28px; font-weight: 800; }
|
||||||
.score-lbl { font-size: 11px; color: #666; margin-top: 2px; }
|
.score-lbl { font-size: 11px; color: #666; margin-top: 2px; }
|
||||||
|
|
||||||
.tier-badge {
|
.tier-badge {
|
||||||
display: inline-block; padding: 5px 14px;
|
display: inline-block; padding: 5px 14px;
|
||||||
border-radius: 14px; font-size: 12px; font-weight: 700;
|
border-radius: 14px; font-size: 12px; font-weight: 700;
|
||||||
border: 2px solid currentColor;
|
border: 2px solid currentColor;
|
||||||
}
|
}
|
||||||
.tier-change {
|
.tier-change {
|
||||||
background: #fff3cd; border: 2px solid #ffc107;
|
background: #fff3cd; border: 2px solid #ffc107;
|
||||||
border-radius: 6px; padding: 12px 16px; margin: 16px 0;
|
border-radius: 6px; padding: 12px 16px; margin: 16px 0;
|
||||||
font-size: 12px; color: #856404;
|
font-size: 12px; color: #856404;
|
||||||
}
|
}
|
||||||
|
|
||||||
.points-display {
|
.points-display {
|
||||||
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; }
|
||||||
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 24px; }
|
.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-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 60px; }
|
||||||
.sig-label { font-size: 11px; color: #555; font-weight: 600; }
|
.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-date-block { border-top: 1.5px solid #333; padding-top: 8px; min-height: 50px; margin-top: 32px; }
|
||||||
|
|
||||||
.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 {
|
||||||
background: #f8d7da; border: 1px solid #f5c6cb;
|
background: #f8d7da; border: 1px solid #f5c6cb;
|
||||||
border-radius: 4px; padding: 6px 12px;
|
border-radius: 4px; padding: 6px 12px;
|
||||||
font-size: 11px; color: #721c24; font-weight: 600;
|
font-size: 11px; color: #721c24; font-weight: 600;
|
||||||
text-align: center; margin-bottom: 16px;
|
text-align: center; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.notice {
|
.notice {
|
||||||
background: #e7f3ff; border-left: 4px solid #2196F3;
|
background: #e7f3ff; border-left: 4px solid #2196F3;
|
||||||
padding: 10px 14px; margin: 16px 0; font-size: 12px;
|
padding: 10px 14px; margin: 16px 0; font-size: 12px;
|
||||||
}
|
}
|
||||||
.policy-context {
|
.policy-context {
|
||||||
background: #f8f9fa; border-left: 3px solid #667eea;
|
background: #f8f9fa; border-left: 3px solid #667eea;
|
||||||
padding: 12px 16px; margin: 12px 0; font-size: 12px; color: #444;
|
padding: 12px 16px; margin: 12px 0; font-size: 12px; color: #444;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="doc-id">
|
<div class="header-left">
|
||||||
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
|
<img src="/static/mpm-logo.png" class="logo" />
|
||||||
Generated: ${generatedAt}
|
<div>
|
||||||
|
<h1>CPAS Individual Violation Record</h1>
|
||||||
|
<p>Message Point Media — Comprehensive Professional Accountability System</p>
|
||||||
</div>
|
</div>
|
||||||
<h1>CPAS Individual Violation Record</h1>
|
</div>
|
||||||
<p>Message Point Media — Confidential HR Document</p>
|
<div class="doc-id">
|
||||||
|
Document ID: CPAS-${v.id.toString().padStart(5,'0')}<br />
|
||||||
|
Generated: ${generatedAt}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 0 4px;">
|
<div style="padding: 0 4px;">
|
||||||
|
|
||||||
<div class="confidential" style="margin-top:16px;">
|
<div class="confidential" style="margin-top:16px;">
|
||||||
⚠ 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>
|
||||||
${row('Employee Name', `<strong>${v.employee_name}</strong>`)}
|
${row('Employee Name', `<strong>${v.employee_name}</strong>`)}
|
||||||
${row('Department', v.department)}
|
${row('Department', v.department)}
|
||||||
${row('Supervisor', v.supervisor)}
|
${row('Supervisor', v.supervisor)}
|
||||||
${row('Witness / Documenting Officer', v.witness_name)}
|
${row('Witness / Documenting Officer', v.witness_name)}
|
||||||
</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>
|
||||||
${row('Violation Type', `<strong>${v.violation_name}</strong>`)}
|
${row('Violation Type', `<strong>${v.violation_name}</strong>`)}
|
||||||
${row('Category', v.category)}
|
${row('Category', v.category)}
|
||||||
${row('Policy Reference', 'Chapter 4, Section 5 — Comprehensive Professional Accountability System (CPAS)')}
|
${row('Policy Reference', 'Chapter 4, Section 5 — Comprehensive Professional Accountability System (CPAS)')}
|
||||||
${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))}
|
${row('Incident Date / Time', formatDateTime(v.incident_date, v.incident_time))}
|
||||||
${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 />
|
||||||
${v.details}
|
${v.details}
|
||||||
</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 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>
|
||||||
|
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">+</div>
|
||||||
<div class="score-box">
|
<div class="score-cell">
|
||||||
<div class="score-cell">
|
<div class="score-num" style="color:#d4af37;">${v.points}</div>
|
||||||
<div class="score-num" style="color:${tier.color};">${activePts}</div>
|
<div class="score-lbl">Points — This Violation</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>
|
</div>
|
||||||
|
<div class="score-cell" style="font-size:28px; font-weight:300; color:#ccc; line-height:1.8;">=</div>
|
||||||
${tierChange ? `
|
<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">
|
<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>
|
||||||
<tr style="background:#f8f9fa;">
|
<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;">Points</th>
|
||||||
<th style="padding:7px 12px; text-align:left; font-size:12px;">Tier</th>
|
<th style="padding:7px 12px; text-align:left; font-size:12px;">Tier</th>
|
||||||
</tr>
|
</tr>
|
||||||
${TIERS.map(t => `
|
${TIERS.map(t => `
|
||||||
<tr style="${newTotal >= t.min && newTotal <= t.max ? 'background:#fff3cd; font-weight:700;' : ''}">
|
<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;">${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>
|
<td style="padding:6px 12px; border-bottom:1px solid #eee; font-size:12px; color:${t.color};">${t.label}</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</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.
|
||||||
Acknowledgement does not imply agreement. The employee may submit a written
|
Acknowledgement does not imply agreement. The employee may submit a written
|
||||||
response within 5 business days.
|
response within 5 business days.
|
||||||
</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>
|
||||||
<div class="sig-date-block">
|
<div>
|
||||||
<div class="sig-label">Date</div>
|
<div class="sig-block"><div class="sig-label">Supervisor / Documenting Officer Signature</div></div>
|
||||||
</div>
|
<div class="sig-date-block"><div class="sig-label">Date</div></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-bar">
|
<div class="footer-bar">
|
||||||
CPAS Violation Record — Document ID: CPAS-${v.id.toString().padStart(5,'0')} |
|
CPAS Violation Record — Document ID: CPAS-${v.id.toString().padStart(5,'0')} |
|
||||||
${v.employee_name} | Incident: ${v.incident_date} |
|
${v.employee_name} | Incident: ${v.incident_date} |
|
||||||
Message Point Media Internal Use Only
|
Message Point Media Internal Use Only
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /padding -->
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user