diff --git a/Dockerfile b/Dockerfile index e400493..c3bebec 100755 --- a/Dockerfile +++ b/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 - 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"] diff --git a/client/src/App.jsx b/client/src/App.jsx index 683e43d..6fdac9c 100755 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 ( -
- -
- {tab === 'dashboard' ? : } -
+ const [tab, setTab] = useState('dashboard'); + return ( +
+ +
+ {tab === 'dashboard' ? : } +
+
+ ); } diff --git a/client/src/components/Dashboard.jsx b/client/src/components/Dashboard.jsx index 33f6b81..6257990 100755 --- a/client/src/components/Dashboard.jsx +++ b/client/src/components/Dashboard.jsx @@ -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 ( -
-
-
-
Company Dashboard
-
Click any employee name to view their full profile
-
-
- setSearch(e.target.value)} /> - -
-
- - {/* ── Stat cards ───────────────────────────────────────── */} -
-
-
{employees.length}
-
Total Employees
-
-
-
{cleanCount}
-
Elite Standing (0 pts)
-
-
-
{activeCount}
-
With Active Points
-
-
-
{atRiskCount}
-
At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)
-
-
-
{maxPoints}
-
Highest Active Score
-
-
- - {/* ── Scoreboard table ─────────────────────────────────── */} - {loading ? ( -

Loading…

- ) : ( - - - - - - - - - - - - - - {filtered.length === 0 && ( - - )} - {filtered.map((emp, i) => { - const risk = isAtRisk(emp.active_points); - const tier = getTier(emp.active_points); - const boundary = nextTierBoundary(emp.active_points); - return ( - - - - - - - - - - ); - })} - -
#EmployeeDepartmentSupervisorTier / StandingActive Points90-Day Violations
No employees found.
{i + 1} - - {risk && ( - - ⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()} - - )} - {emp.department || '—'}{emp.supervisor || '—'}{emp.active_points}{emp.violation_count}
- )} - - {/* ── Employee profile modal ───────────────────────────── */} - {selectedId && ( - { setSelectedId(null); load(); }} - /> - )} + return ( +
+
+
+
Company Dashboard
+
Click any employee name to view their full profile
- ); +
+ setSearch(e.target.value)} /> + +
+
+ +
+
+
{employees.length}
+
Total Employees
+
+
+
{cleanCount}
+
Elite Standing (0 pts)
+
+
+
{activeCount}
+
With Active Points
+
+
+
{atRiskCount}
+
At Risk (≤{AT_RISK_THRESHOLD} pts to next tier)
+
+
+
{maxPoints}
+
Highest Active Score
+
+
+ + {loading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map((emp, i) => { + const risk = isAtRisk(emp.active_points); + const tier = getTier(emp.active_points); + const boundary = nextTierBoundary(emp.active_points); + return ( + + + + + + + + + + ); + })} + +
#EmployeeDepartmentSupervisorTier / StandingActive Points90-Day Violations
No employees found.
{i + 1} + + {risk && ( + + ⚠ {boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()} + + )} + {emp.department || '—'}{emp.supervisor || '—'}{emp.active_points}{emp.violation_count}
+ )} + + {selectedId && ( + { setSelectedId(null); load(); }} + /> + )} +
+ ); } diff --git a/pdf/template.js b/pdf/template.js index 16fdc4e..76da038 100755 --- a/pdf/template.js +++ b/pdf/template.js @@ -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 ` - - ${label} - ${value || '—'} - `; + return ` + + ${label} + ${value || '—'} + `; } 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 ` + return ` -
-
- Document ID: CPAS-${v.id.toString().padStart(5,'0')}
- Generated: ${generatedAt} +
+ +
+

CPAS Individual Violation Record

+

Message Point Media — Comprehensive Professional Accountability System

-

CPAS Individual Violation Record

-

Message Point Media — Confidential HR Document

+
+
+ Document ID: CPAS-${v.id.toString().padStart(5,'0')}
+ Generated: ${generatedAt} +
- ⚠ CONFIDENTIAL — For authorized HR and management use only + ⚠ CONFIDENTIAL — For authorized HR and management use only
-
-
Employee Information
- - ${row('Employee Name', `${v.employee_name}`)} - ${row('Department', v.department)} - ${row('Supervisor', v.supervisor)} - ${row('Witness / Documenting Officer', v.witness_name)} -
+
Employee Information
+ + ${row('Employee Name', `${v.employee_name}`)} + ${row('Department', v.department)} + ${row('Supervisor', v.supervisor)} + ${row('Witness / Documenting Officer', v.witness_name)} +
-
-
Violation Details
- - ${row('Violation Type', `${v.violation_name}`)} - ${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')} -
- - ${v.details ? ` +
Violation Details
+ + ${row('Violation Type', `${v.violation_name}`)} + ${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')} +
+ ${v.details ? `
- Incident Details:
- ${v.details} + Incident Details:
+ ${v.details}
` : ''}
-
-
CPAS Point Assessment
- -
-
${v.points}
-
Points Assessed — This Violation
+
CPAS Point Assessment
+
+
${v.points}
+
Points Assessed — This Violation
+
+
+
+
${activePts}
+
Active Points (Prior)
+
+ ${tier.label} +
- -
-
-
${activePts}
-
Active Points (Prior)
-
- ${tier.label} -
-
-
+
-
-
${v.points}
-
Points — This Violation
-
-
=
-
-
${newTotal}
-
New Active Total
-
- ${newTier.label} -
-
+
+
+
+
${v.points}
+
Points — This Violation
- - ${tierChange ? ` +
=
+
+
${newTotal}
+
New Active Total
+
+ ${newTier.label} +
+
+
+ ${tierChange ? `
- ⚠ Tier Escalation: This violation advances the employee from - ${tier.label} to ${newTier.label}. - Review associated tier consequences per the Employee Handbook. + ⚠ Tier Escalation: This violation advances the employee from + ${tier.label} to ${newTier.label}.
` : ''}
-
-
CPAS Tier Reference
- - - - - - ${TIERS.map(t => ` - - - - `).join('')} -
PointsTier
${t.min === 30 ? '30+' : t.min + '–' + t.max}${t.label}
+
CPAS Tier Reference
+ + + + + + ${TIERS.map(t => ` + + + + `).join('')} +
PointsTier
${t.min === 30 ? '30+' : t.min + '–' + t.max}${t.label}
-
- Employee Notice: 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. + Employee Notice: 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.
-
-
Acknowledgement & Signatures
-
-

- 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. -

-
-
-
-
Employee Signature
-
-
-
Date
-
-
-
-
-
Supervisor / Documenting Officer Signature
-
-
-
Date
-
-
-
+
Acknowledgement & Signatures
+
+

+ 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. +

+
+
+
Employee Signature
+
Date
+
+
+
Supervisor / Documenting Officer Signature
+
Date
+
+
-
+
`; }