Initial commit of Docker project

This commit is contained in:
2026-03-06 11:33:32 -06:00
commit 45d785964d
15 changed files with 1058 additions and 0 deletions

12
client/index.html Executable file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CPAS Violation Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

19
client/package.json Executable file
View File

@@ -0,0 +1,19 @@
{
"name": "cpas-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.8",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
}
}

57
client/src/App.jsx Executable file
View File

@@ -0,0 +1,57 @@
import React, { useEffect, useState } from 'react';
import ViolationForm from './components/ViolationForm';
const styles = {
body: {
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
minHeight: '100vh',
padding: '20px',
margin: 0,
},
container: {
maxWidth: '1200px',
margin: '0 auto',
background: 'white',
borderRadius: '12px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
overflow: 'hidden',
},
header: {
background: 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)',
color: 'white',
padding: '30px',
textAlign: 'center',
},
statusBar: {
fontSize: '11px',
color: '#aaa',
marginTop: '6px',
}
};
export default function App() {
const [apiStatus, setApiStatus] = useState('checking...');
useEffect(() => {
fetch('/api/health')
.then(r => r.json())
.then(() => setApiStatus('● API connected'))
.catch(() => setApiStatus('⚠ API unreachable'));
}, []);
return (
<div style={styles.body}>
<div style={styles.container}>
<div style={styles.header}>
<h1 style={{ margin: 0, fontSize: '28px' }}>CPAS Violation Documentation System</h1>
<p style={{ margin: '8px 0 0', fontSize: '14px', opacity: 0.9 }}>
Generate Individual Violation Records with Contextual Fields
</p>
<p style={styles.statusBar}>{apiStatus}</p>
</div>
<ViolationForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { violationData, violationGroups } from '../data/violations';
const s = {
content: { padding: '40px' },
section: { background: '#f8f9fa', borderLeft: '4px solid #667eea', padding: '20px', marginBottom: '30px', borderRadius: '4px' },
sectionTitle: { color: '#2c3e50', fontSize: '20px', marginBottom: '15px' },
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
item: { display: 'flex', flexDirection: 'column' },
label: { fontWeight: 600, color: '#555', marginBottom: '5px', fontSize: '13px' },
input: { padding: '10px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit' },
fullCol: { gridColumn: '1 / -1' },
contextBox: { background: '#f1f3f5', border: '1px solid #ced4da', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#444', marginTop: '4px' },
pointBox: { background: '#fff3cd', border: '2px solid #ffc107', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
slider: { width: '100%', marginTop: '10px' },
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#667eea', margin: '10px 0' },
btnRow: { display: 'flex', gap: '15px', justifyContent: 'center', marginTop: '30px', flexWrap: 'wrap' },
btnPrimary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' },
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: '#6c757d', color: 'white', textTransform: 'uppercase', letterSpacing: '0.5px' },
note: { background: '#e7f3ff', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px' },
statusOk: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#d4edda', color: '#155724', border: '1px solid #c3e6cb' },
statusErr: { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb' },
};
const EMPTY_FORM = {
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
violationType: '', incidentDate: '', incidentTime: '',
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
};
export default function ViolationForm() {
const [employees, setEmployees] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [violation, setViolation] = useState(null);
const [status, setStatus] = useState(null);
useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
}, []);
const handleEmployeeSelect = e => {
const emp = employees.find(x => x.id === parseInt(e.target.value));
if (!emp) return;
setForm(prev => ({ ...prev, employeeId: emp.id, employeeName: emp.name, department: emp.department || '', supervisor: emp.supervisor || '' }));
};
const handleViolationChange = e => {
const key = e.target.value;
const v = violationData[key] || null;
setViolation(v);
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
};
const handleChange = e => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
const handleSubmit = async e => {
e.preventDefault();
if (!form.violationType) return setStatus({ ok: false, msg: 'Please select a violation type.' });
try {
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
const employeeId = empRes.data.id;
const empList = await axios.get('/api/employees');
setEmployees(empList.data);
await axios.post('/api/violations', {
employee_id: employeeId, violation_type: form.violationType,
violation_name: violation?.name || form.violationType,
category: violation?.category || 'General', points: parseInt(form.points),
incident_date: form.incidentDate, incident_time: form.incidentTime || null,
location: form.location || null, details: form.additionalDetails || null,
witness_name: form.witnessName || null,
});
setStatus({ ok: true, msg: '✓ Violation recorded successfully' });
setForm(EMPTY_FORM);
setViolation(null);
} catch (err) {
setStatus({ ok: false, msg: '✗ Error: ' + (err.response?.data?.error || err.message) });
}
};
const showField = f => violation?.fields?.includes(f);
return (
<div style={s.content}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Employee Information</h2>
{employees.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<label style={s.label}>Quick-Select Existing Employee:</label>
<select style={s.input} onChange={handleEmployeeSelect} value={form.employeeId || ''}>
<option value="">-- Select existing or enter new below --</option>
{employees.map(e => (
<option key={e.id} value={e.id}>{e.name}{e.department ? `${e.department}` : ''}</option>
))}
</select>
</div>
)}
<div style={s.grid}>
{[['employeeName','Employee Name','text','John Doe'],['department','Department','text','Engineering'],['supervisor','Supervisor Name','text','Jane Smith'],['witnessName','Witness Name (Officer)','text','Officer Name']].map(([name,label,type,ph]) => (
<div key={name} style={s.item}>
<label style={s.label}>{label}:</label>
<input style={s.input} type={type} name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Violation Details</h2>
<div style={s.grid}>
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
<option value="">-- Select Violation Type --</option>
{Object.entries(violationGroups).map(([group, items]) => (
<optgroup key={group} label={group}>
{items.map(v => <option key={v.key} value={v.key}>{v.name}</option>)}
</optgroup>
))}
</select>
{violation && (
<div style={s.contextBox}>
<strong>{violation.name}</strong> {violation.description}<br />
<span style={{ fontSize: '11px', color: '#666' }}>{violation.chapter}</span>
</div>
)}
</div>
<div style={s.item}>
<label style={s.label}>Incident Date:</label>
<input style={s.input} type="date" name="incidentDate" value={form.incidentDate} onChange={handleChange} required />
</div>
{showField('time') && (
<div style={s.item}>
<label style={s.label}>Incident Time:</label>
<input style={s.input} type="time" name="incidentTime" value={form.incidentTime} onChange={handleChange} />
</div>
)}
{showField('minutes') && (
<div style={s.item}>
<label style={s.label}>Minutes Late:</label>
<input style={s.input} type="number" name="minutesLate" value={form.minutesLate} onChange={handleChange} placeholder="15" />
</div>
)}
{showField('amount') && (
<div style={s.item}>
<label style={s.label}>Amount / Value:</label>
<input style={s.input} type="text" name="amount" value={form.amount} onChange={handleChange} placeholder="$150.00" />
</div>
)}
{showField('location') && (
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Location / Context:</label>
<input style={s.input} type="text" name="location" value={form.location} onChange={handleChange} placeholder="Office, vehicle, facility area, etc." />
</div>
)}
{showField('description') && (
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Additional Details:</label>
<textarea style={{ ...s.input, resize: 'vertical', minHeight: '80px' }} name="additionalDetails" value={form.additionalDetails} onChange={handleChange} placeholder="Provide specific context, observations, or details..." />
</div>
)}
</div>
{violation && (
<div style={s.pointBox}>
<h4 style={{ color: '#856404', marginBottom: '10px' }}>CPAS Point Assessment</h4>
<p style={{ margin: 0 }}>
{violation.name}: {violation.minPoints === violation.maxPoints
? `${violation.minPoints} Points (Fixed)`
: `${violation.minPoints}${violation.maxPoints} Points`}
</p>
<input style={s.slider} type="range" name="points" min={violation.minPoints} max={violation.maxPoints} value={form.points} onChange={handleChange} />
<div style={s.pointValue}>{form.points} Points</div>
<p style={{ fontSize: '12px', color: '#666' }}>Adjust to reflect severity and context</p>
</div>
)}
</div>
<div style={s.note}>
<strong>Note:</strong> Submitting saves the violation to the database linked to the employee record. PDF generation available in Phase 3.
</div>
<div style={s.btnRow}>
<button type="submit" style={s.btnPrimary}>Submit Violation</button>
<button type="button" style={s.btnSecondary} onClick={() => { setForm(EMPTY_FORM); setViolation(null); setStatus(null); }}>Clear Form</button>
</div>
{status && <div style={status.ok ? s.statusOk : s.statusErr}>{status.msg}</div>}
</form>
</div>
);
}

248
client/src/data/violations.js Executable file
View File

@@ -0,0 +1,248 @@
export const violationData = {
tardy: {
name: 'Tardy Core Hours', category: 'Attendance & Punctuality',
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
fields: ['time', 'minutes', 'description'],
description: 'Arriving 7+ minutes after 9:00 AM or start of mandatory meeting without prior excuse'
},
unplanned_absence: {
name: 'Unplanned Absence', category: 'Attendance & Punctuality',
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Absence from Core Hours without 48-hour notification, excluding verified emergencies'
},
chronic_underscheduling: {
name: 'Chronic Under-Scheduling', category: 'Attendance & Punctuality',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Consistently failing to meet 40-hour weekly baseline'
},
pto_exhausted: {
name: 'Absence - PTO Exhausted', category: 'Attendance & Punctuality',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Any absence after PTO bank reaches zero'
},
shadow_absenteeism: {
name: 'Shadow Absenteeism', category: 'Attendance & Punctuality',
minPoints: 5, maxPoints: 20, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to record partial-day absences or habitual PTO system bypass (20 pts for recidivists)'
},
manual_punch_1st: {
name: 'Manual Punch Correction (1st)', category: 'Administrative Integrity',
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'First failure to punch in/out requiring manual audit'
},
manual_punch_2nd: {
name: 'Manual Punch Correction (2nd)', category: 'Administrative Integrity',
minPoints: 2, maxPoints: 2, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Second failure requiring written action plan'
},
manual_punch_3rd: {
name: 'Manual Punch Correction (3rd / Tier 1)', category: 'Administrative Integrity',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Repeated timekeeping negligence triggering formal Tier 1 realignment'
},
geolocation_1st: {
name: 'Geolocation Integrity (1st)', category: 'Administrative Integrity',
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Recording blind punch with location services disabled'
},
geolocation_2nd: {
name: 'Geolocation Integrity (2nd)', category: 'Administrative Integrity',
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Subsequent attempt to bypass location safeguards'
},
point_of_work: {
name: 'Point-of-Work Integrity', category: 'Administrative Integrity',
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Clocking in before arriving at assigned post or for personal errands'
},
financial_chargeback: {
name: 'Financial Stewardship / Chargeback', category: 'Financial Stewardship',
minPoints: 1, maxPoints: 1, chapter: 'Chapter 4, Section 5',
fields: ['amount', 'description'],
description: 'Monthly assessment for unsubstantiated expenses requiring chargeback'
},
receipt_negligence: {
name: 'Receipt Negligence', category: 'Financial Stewardship',
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
fields: ['amount', 'description'],
description: 'Frequent failure to provide company card expense documentation'
},
failure_to_respond: {
name: 'Failure to Respond', category: 'Operational Response',
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to respond promptly to internal/external requests during Core Hours'
},
sunset_rule: {
name: 'Sunset Rule Violation', category: 'Operational Response',
minPoints: 1, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to provide response or status update with commitment date by end of business day'
},
double_ask: {
name: 'Double Ask Friction', category: 'Operational Response',
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Forcing client to ask twice for same information due to employee neglect'
},
missed_deadline_internal: {
name: 'Missed Deadline - Internal', category: 'Operational Response',
minPoints: 3, maxPoints: 3, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to meet internal project milestones'
},
missed_deadline_client: {
name: 'Missed Deadline - Client', category: 'Operational Response',
minPoints: 7, maxPoints: 7, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to meet high-impact client-facing deadline'
},
commitment_breach: {
name: 'Commitment Breach', category: 'Operational Response',
minPoints: 4, maxPoints: 4, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failing to meet commitment date without proactive prior notification'
},
communication_gap: {
name: 'Communication Gap (15-min window)', category: 'Operational Response',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to respond within 15-minute window due to mobile device distraction'
},
quality_recidivism: {
name: 'Quality Recidivism', category: 'Operational Response',
minPoints: 4, maxPoints: 4, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Repetition of technical/administrative error previously corrected'
},
technical_negligence: {
name: 'Technical Negligence', category: 'Operational Response',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Performance error resulting in rework, data loss, or equipment damage'
},
appearance: {
name: 'Professional Appearance Violation', category: 'Professional Conduct',
minPoints: 1, maxPoints: 3, chapter: 'Chapter 2, Section 9',
fields: ['time', 'location', 'description'],
description: 'Failure to maintain dress code standards (shirts, pants, shoes required)'
},
active_consumption: {
name: 'Active Consumption Media', category: 'Professional Conduct',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['time', 'description'],
description: 'Interactive social media/gaming during Core Hours'
},
tobacco_debris: {
name: 'Tobacco Facility Debris', category: 'Professional Conduct',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Failure to maintain clean smoking area or flicking debris on grounds'
},
passive_insubordination: {
name: 'Passive Insubordination', category: 'Professional Conduct',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Ignoring reasonable requests, emails, or syncs without open dissent'
},
lockdown_violation: {
name: 'Lockdown Violation', category: 'Professional Conduct',
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Using non-work media while under Tier 2 Administrative Friction'
},
vehicle_stewardship: {
name: 'Vehicle Stewardship', category: 'Professional Conduct',
minPoints: 10, maxPoints: 10, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Persistent tobacco-free transit violation (odor/debris in company vehicle)'
},
defiant_insubordination: {
name: 'Defiant Insubordination', category: 'Professional Conduct',
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Openly refusing legal, ethical, or professional directive from management'
},
benefit_documentation: {
name: 'Benefit Documentation Failure', category: 'Professional Conduct',
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to provide insurance records for Workers Comp'
},
professional_dishonesty: {
name: 'Professional Dishonesty', category: 'Professional Conduct',
minPoints: 20, maxPoints: 20, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Falsifying time records, expenses, or reasons for absence'
},
wfh_submittal: {
name: 'WFH Submittal Failure', category: 'Work From Home',
minPoints: 1, maxPoints: 5, chapter: 'Chapter 4, Section 4.1',
fields: ['description'],
description: 'Failure to provide work-product summary or misrepresenting hours worked'
},
safety_minor: {
name: 'Safety Violation - Minor', category: 'Safety & Security',
minPoints: 1, maxPoints: 10, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Minor to moderate safety standard violations without immediate injury'
},
policy_isp: {
name: 'Policy Non-Alignment - ISP', category: 'Safety & Security',
minPoints: 5, maxPoints: 20, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Failure to adhere to Information Security Policy protocols'
},
workspace_safety: {
name: 'Workspace Safety Neglect', category: 'Safety & Security',
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Failure to maintain clean workspace or minor safety negligence'
},
distracted_driving: {
name: 'Distracted Driving', category: 'Safety & Security',
minPoints: 15, maxPoints: 15, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Use of handheld mobile devices while operating vehicle for company business'
},
operational_sabotage: {
name: 'Operational Sabotage', category: 'Safety & Security',
minPoints: 20, maxPoints: 20, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Willful disregard for security/safety protocols resulting in breach or injury'
},
impairment_redzone: {
name: 'Impairment in Red Zone', category: 'Safety & Security',
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Operating machinery or working in Fabrication Area while under influence'
},
child_redzone: {
name: 'Child in Red Zone', category: 'Safety & Security',
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
fields: ['location', 'description'],
description: 'Bringing minor into active Fabrication Area (Suite 24/25)'
},
i9_falsification: {
name: 'I-9 Eligibility Falsification', category: 'Safety & Security',
minPoints: 30, maxPoints: 30, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Falsifying work authorization or identity documentation'
}
};
export const violationGroups = Object.entries(violationData).reduce((acc, [key, val]) => {
if (!acc[val.category]) acc[val.category] = [];
acc[val.category].push({ key, ...val });
return acc;
}, {});

9
client/src/main.jsx Executable file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

18
client/vite.config.js Executable file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},
build: {
outDir: 'dist'
}
});