Initial commit of Docker project
This commit is contained in:
9
.dockerignore
Executable file
9
.dockerignore
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
client/dist
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
data/
|
||||||
62
Dockerfile
Executable file
62
Dockerfile
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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
|
||||||
|
# Copies only what's needed to run — no dev tools, no node_modules for client.
|
||||||
|
# Final image is lean (~180MB) and ready to run with zero host setup.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
# Chromium + deps for Phase 3 Puppeteer PDF generation
|
||||||
|
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 deps from builder
|
||||||
|
COPY --from=builder /build/node_modules ./node_modules
|
||||||
|
COPY --from=builder /build/client/dist ./client/dist
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY server.js ./
|
||||||
|
COPY db/ ./db/
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Ensure data directory exists (will be overridden by volume mount)
|
||||||
|
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
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
73
README.md
Executable file
73
README.md
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
# CPAS Violation Tracker
|
||||||
|
|
||||||
|
Single-container Dockerized web app for CPAS violation documentation.
|
||||||
|
Built with React + Vite (frontend), Node.js + Express (backend), SQLite (database).
|
||||||
|
|
||||||
|
## The only requirement on your machine: Docker Desktop
|
||||||
|
|
||||||
|
Everything else — Node.js, npm, React build — happens inside Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart (Local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build the image (installs all deps + compiles React inside Docker)
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
|
||||||
|
# 2. Run it
|
||||||
|
docker run -d --name cpas-tracker \
|
||||||
|
-p 3001:3001 \
|
||||||
|
-v cpas-data:/data \
|
||||||
|
cpas-tracker
|
||||||
|
|
||||||
|
# 3. Open
|
||||||
|
# http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Export for Unraid
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Then follow README_UNRAID_INSTALL.md.
|
||||||
|
|
||||||
|
## Update After Code Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
docker stop cpas-tracker && docker rm cpas-tracker
|
||||||
|
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cpas-violation-tracker/
|
||||||
|
├── Dockerfile # Multi-stage: builds React + runs Express
|
||||||
|
├── .dockerignore
|
||||||
|
├── package.json # Backend (Express) deps
|
||||||
|
├── server.js # API + static file server
|
||||||
|
├── db/
|
||||||
|
│ ├── schema.sql # Tables + 90-day score view
|
||||||
|
│ └── database.js # SQLite connection
|
||||||
|
└── client/ # React frontend (Vite)
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.jsx
|
||||||
|
├── App.jsx
|
||||||
|
├── data/
|
||||||
|
│ └── violations.js # All CPAS violation definitions
|
||||||
|
└── components/
|
||||||
|
└── ViolationForm.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
- [x] Phase 1 — Container scaffold, SQLite schema, base React form
|
||||||
|
- [ ] Phase 2 — Employee history, prior violation highlighting
|
||||||
|
- [ ] Phase 3 — Puppeteer PDF generation
|
||||||
|
- [ ] Phase 4 — Dashboard, CPAS scores, tier warnings
|
||||||
|
- [ ] Phase 5 — Recidivist point auto-suggest
|
||||||
219
README_UNRAID_INSTALL.md
Executable file
219
README_UNRAID_INSTALL.md
Executable file
@@ -0,0 +1,219 @@
|
|||||||
|
# CPAS Violation Tracker — Unraid Installation Guide
|
||||||
|
|
||||||
|
> **Applies to:** Unraid 6.12+ | Single container | Port 3001
|
||||||
|
> **Host requirement:** Docker Desktop only — no Node.js needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Docker image is fully self-contained. All dependencies and the compiled
|
||||||
|
React frontend are baked in during the build. You only need Docker Desktop
|
||||||
|
on your local machine to build and export the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Build the Docker Image Locally
|
||||||
|
|
||||||
|
Open a terminal in the unzipped project folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
```
|
||||||
|
|
||||||
|
This single command:
|
||||||
|
- Installs backend (Node/Express) dependencies
|
||||||
|
- Installs frontend (React/Vite) dependencies
|
||||||
|
- Compiles the React app
|
||||||
|
- Packages everything into one lean image
|
||||||
|
|
||||||
|
No npm, no Node.js required on your machine beyond Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Export the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Transfer to Unraid
|
||||||
|
|
||||||
|
### Option A — Windows SMB (Recommended, no terminal)
|
||||||
|
1. Open File Explorer → address bar → `\\[YOUR-UNRAID-IP]`
|
||||||
|
2. Open the **appdata** share
|
||||||
|
3. Create a folder named `cpas`
|
||||||
|
4. Drag `cpas-tracker.tar.gz` into `\\[YOUR-UNRAID-IP]\appdata\cpas\`
|
||||||
|
|
||||||
|
### Option B — SCP (Mac/Linux)
|
||||||
|
```bash
|
||||||
|
scp cpas-tracker.tar.gz root@[YOUR-UNRAID-IP]:/mnt/user/appdata/cpas/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Prepare Unraid (Terminal — one time only)
|
||||||
|
|
||||||
|
1. In Unraid GUI → **Tools** → **Terminal**
|
||||||
|
2. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/user/appdata/cpas/db
|
||||||
|
docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
Loaded image: cpas-tracker:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Close the terminal — no further terminal use needed for normal operation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Add the Container in Unraid GUI
|
||||||
|
|
||||||
|
### 5.1 Navigate to Docker tab
|
||||||
|
1. Click **Docker** in the top nav
|
||||||
|
2. Confirm Docker is **Enabled** (green toggle)
|
||||||
|
3. Scroll to bottom → click **Add Container**
|
||||||
|
4. Toggle **Advanced View ON** (top-right of the form)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Basic Settings
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Name** | `cpas-tracker` |
|
||||||
|
| **Repository** | `cpas-tracker` |
|
||||||
|
| **Docker Hub URL** | *(leave blank — local image)* |
|
||||||
|
| **WebUI** | `http://[IP]:[PORT:3001]` |
|
||||||
|
| **Network Type** | `Bridge` |
|
||||||
|
| **Privileged** | `Off` |
|
||||||
|
| **Restart Policy** | `Unless Stopped` |
|
||||||
|
| **Console shell** | `bash` |
|
||||||
|
|
||||||
|
> Setting the WebUI field enables a one-click launch icon on the Docker tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Port Mapping
|
||||||
|
|
||||||
|
Click **Add another Path, Port, Variable, Label or Device**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Config Type | `Port` |
|
||||||
|
| Name | `Web UI` |
|
||||||
|
| Container Port | `3001` |
|
||||||
|
| Host Port | `3001` |
|
||||||
|
| Protocol | `TCP` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 Volume Mapping (Database Persistence)
|
||||||
|
|
||||||
|
Click **Add another Path, Port, Variable, Label or Device**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Config Type | `Path` |
|
||||||
|
| Name | `Database` |
|
||||||
|
| Container Path | `/data` |
|
||||||
|
| Host Path | `/mnt/user/appdata/cpas/db` |
|
||||||
|
| Access Mode | `Read/Write` |
|
||||||
|
|
||||||
|
> The SQLite database lives here and survives container restarts and image updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 Environment Variables
|
||||||
|
|
||||||
|
Click **Add another Path, Port, Variable, Label or Device** for each:
|
||||||
|
|
||||||
|
**Variable 1 — Port**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Config Type | `Variable` |
|
||||||
|
| Name | `Port` |
|
||||||
|
| Key | `PORT` |
|
||||||
|
| Value | `3001` |
|
||||||
|
|
||||||
|
**Variable 2 — Database Path**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Config Type | `Variable` |
|
||||||
|
| Name | `Database Path` |
|
||||||
|
| Key | `DB_PATH` |
|
||||||
|
| Value | `/data/cpas.db` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 Apply
|
||||||
|
|
||||||
|
1. Click **Apply** at the bottom
|
||||||
|
2. Watch the progress log — wait for "Container started"
|
||||||
|
3. Click **Done**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6 — Verify
|
||||||
|
|
||||||
|
1. Docker tab → **cpas-tracker** should show a green icon
|
||||||
|
2. Click the container icon → **WebUI**
|
||||||
|
Or open: `http://[YOUR-UNRAID-IP]:3001`
|
||||||
|
3. Confirm **● API connected** appears in the header
|
||||||
|
4. Health check: `http://[YOUR-UNRAID-IP]:3001/api/health`
|
||||||
|
→ `{"status":"ok","timestamp":"..."}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7 — Updating After Code Changes
|
||||||
|
|
||||||
|
### Locally:
|
||||||
|
```bash
|
||||||
|
docker build -t cpas-tracker .
|
||||||
|
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transfer to Unraid (same as Part 3)
|
||||||
|
|
||||||
|
### On Unraid — GUI only after first load:
|
||||||
|
1. Copy new tar.gz to Unraid (SMB drag-and-drop)
|
||||||
|
2. **Tools → Terminal** → `docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz`
|
||||||
|
3. **Docker tab** → click `cpas-tracker` icon → **Restart**
|
||||||
|
|
||||||
|
> Your database at `/mnt/user/appdata/cpas/db/cpas.db` is never touched during updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Fix |
|
||||||
|
|---|---|
|
||||||
|
| Container won't start | Docker tab → container icon → **Logs** |
|
||||||
|
| Port 3001 conflict | Change Host Port to `3002` in Edit Container |
|
||||||
|
| "API unreachable" in UI | Confirm green icon, check Logs, try Restart |
|
||||||
|
| DB permission error | Terminal: `chmod 755 /mnt/user/appdata/cpas/db` |
|
||||||
|
| Inspect DB directly | Terminal: `docker exec -it cpas-tracker sh` then `sqlite3 /data/cpas.db ".tables"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference — Unraid Docker Tab Actions
|
||||||
|
|
||||||
|
| Action | Steps |
|
||||||
|
|---|---|
|
||||||
|
| Open app | Container icon → WebUI |
|
||||||
|
| View logs | Container icon → Logs |
|
||||||
|
| Restart | Container icon → Restart |
|
||||||
|
| Edit settings | Container icon → Edit |
|
||||||
|
| Stop/Start | Container icon → Stop / Start |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*CPAS Violation Tracker — Phase 1 | Message Point Media internal use*
|
||||||
12
client/index.html
Executable file
12
client/index.html
Executable 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
19
client/package.json
Executable 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
57
client/src/App.jsx
Executable 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
client/src/components/ViolationForm.jsx
Executable file
189
client/src/components/ViolationForm.jsx
Executable 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
248
client/src/data/violations.js
Executable 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
9
client/src/main.jsx
Executable 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
18
client/vite.config.js
Executable 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
19
db/database.js
Executable file
19
db/database.js
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || '/data/cpas.db';
|
||||||
|
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||||
|
|
||||||
|
const dbDir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
console.log(`[DB] Connected: ${DB_PATH}`);
|
||||||
|
module.exports = db;
|
||||||
36
db/schema.sql
Executable file
36
db/schema.sql
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
department TEXT,
|
||||||
|
supervisor TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS violations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
|
violation_type TEXT NOT NULL,
|
||||||
|
violation_name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
points INTEGER NOT NULL,
|
||||||
|
incident_date DATE NOT NULL,
|
||||||
|
incident_time TEXT,
|
||||||
|
location TEXT,
|
||||||
|
details TEXT,
|
||||||
|
submitted_by TEXT,
|
||||||
|
witness_name TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
|
||||||
|
SELECT
|
||||||
|
e.id AS employee_id,
|
||||||
|
e.name AS employee_name,
|
||||||
|
e.department,
|
||||||
|
COALESCE(SUM(v.points), 0) AS active_points,
|
||||||
|
COUNT(v.id) AS violation_count
|
||||||
|
FROM employees e
|
||||||
|
LEFT JOIN violations v
|
||||||
|
ON v.employee_id = e.id
|
||||||
|
AND v.incident_date >= DATE('now', '-90 days')
|
||||||
|
GROUP BY e.id;
|
||||||
23
package.json
Executable file
23
package.json
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "cpas-tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CPAS Violation Tracker — single container, fully self-contained",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"docker:build": "docker build -t cpas-tracker .",
|
||||||
|
"docker:run": "docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker",
|
||||||
|
"docker:stop": "docker stop cpas-tracker && docker rm cpas-tracker",
|
||||||
|
"docker:logs": "docker logs -f cpas-tracker",
|
||||||
|
"docker:export": "docker save cpas-tracker | gzip > cpas-tracker.tar.gz"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^9.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
server.js
Executable file
65
server.js
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('./db/database');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'client', 'dist')));
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/employees', (req, res) => {
|
||||||
|
const rows = db.prepare('SELECT id, name, department, supervisor FROM employees ORDER BY name ASC').all();
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/employees', (req, res) => {
|
||||||
|
const { name, department, supervisor } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const existing = db.prepare('SELECT * FROM employees WHERE LOWER(name) = LOWER(?)').get(name);
|
||||||
|
if (existing) {
|
||||||
|
if (department || supervisor) {
|
||||||
|
db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
|
||||||
|
.run(department || null, supervisor || null, existing.id);
|
||||||
|
}
|
||||||
|
return res.json({ ...existing, department, supervisor });
|
||||||
|
}
|
||||||
|
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)').run(name, department || null, supervisor || null);
|
||||||
|
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/violations/employee/:employeeId', (req, res) => {
|
||||||
|
const rows = db.prepare('SELECT * FROM violations WHERE employee_id = ? ORDER BY incident_date DESC').all(req.params.employeeId);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/employees/:employeeId/score', (req, res) => {
|
||||||
|
const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.employeeId);
|
||||||
|
res.json(row || { active_points: 0, violation_count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/violations', (req, res) => {
|
||||||
|
const { employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name } = req.body;
|
||||||
|
if (!employee_id || !violation_type || !points || !incident_date) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: employee_id, violation_type, points, incident_date' });
|
||||||
|
}
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO violations (employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(employee_id, violation_type, violation_name || violation_type, category || 'General', points, incident_date, incident_time || null, location || null, details || null, submitted_by || null, witness_name || null);
|
||||||
|
res.status(201).json({ id: result.lastInsertRowid });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[CPAS] Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user