29 Commits

Author SHA1 Message Date
jason 28e5f4e4e6 fix(healthcheck): probe 127.0.0.1 instead of localhost (IPv6 ::1 made wget fail)
Build and Push Docker Image / build (push) Successful in 54s
2026-07-28 16:26:46 -05:00
jason 6f7d840145 ci: push per-SHA image tag (PORT rollback support) [skip ci] 2026-07-28 01:13:56 -05:00
jason c6fe49f970 ci: unify to canonical docker-build workflow (drift labels baked in)
Build and Push Docker Image / build (push) Successful in 59s
2026-07-19 13:15:24 -05:00
Jason Stedwell 3c4536921f ci: trigger PORT auto-redeploy after image push
Build and Push Docker Image / build (push) Successful in 1m1s
2026-07-18 20:25:26 -05:00
Jason Stedwell 0c880c9598 Untrack build output and local artifacts; gitignore them
Build and Push Docker Image / build (push) Successful in 28s
The image is built in CI (.gitea/workflows/docker-build.yml) from the
Dockerfile and pulled from registry.alwisp.com on Unraid. The Dockerfile
recompiles the client (vite build) inside the build and .dockerignore already
excludes these paths from the build context, so tracking them in git served no
purpose.

- Untrack client/dist (regenerated by the Docker build; logo + version stub
  sources live in client/public).
- Gitignore client/dist/, data/ (local SQLite volume), and *.tar.gz / *.zip
  (local `docker save` exports), alongside the already-ignored node_modules/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:13:07 -05:00
Jason Stedwell 7da4163d3b Untrack client/node_modules and gitignore it
Build and Push Docker Image / build (push) Successful in 30s
node_modules was vendored into the repo, which caused platform-specific
rollup/esbuild binary churn on every local install. Dependencies are
installed fresh during the Docker build, so there's no reason to track them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:09:26 -05:00
Jason Stedwell aef4ae60cb Docs consolidation + audit-log timezone fix + PTO-Exhausted sliding scale
Build and Push Docker Image / build (push) Successful in 49s
Documentation:
- Consolidate 6 doc surfaces to 3. Merge Unraid guide into README and
  fold durable mobile rules into AGENTS.md; remove README_UNRAID_INSTALL.md,
  MOBILE_RESPONSIVE.md, and the misplaced "CPAS Violation Tracker.md" vault note.
- Fix drift across README/AGENTS/in-app guide: clean-cycle roll-off model
  (not per-violation 90-day window), auth documented as shipped, dropped
  active_cpas_scores view, correct table count (8 tables/0 views), auth
  endpoints + ADMIN_PASSWORD in run commands, roadmap updated.

Fixes:
- Audit log / amendment timestamps: SQLite CURRENT_TIMESTAMP is UTC with no
  zone marker; new Date() parsed the space-separated form as local time,
  shifting entries +5h in Chicago/CDT. Pin the bare form to UTC before
  formatting in AuditLog.jsx and AmendViolationModal.jsx.
- Absence - PTO Exhausted: fixed 5 pts -> sliding scale 1-5.

Rebuild client bundle to include the above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:07:51 -05:00
jason cd0064ce5b Fix docker-build.yml push trigger branch to match default branch
Build and Push Docker Image / build (push) Successful in 46s
2026-07-02 10:07:33 -05:00
jason 043fa63ddf Update docker-build.yml workflow 2026-07-02 10:03:12 -05:00
jason 69445381d5 docker-build.yml: build + push :latest only, no cleanup step
Build and Push Docker Image / build (push) Successful in 1m23s
Operator preference: build and push the :latest image to the registry and
leave it; no per-build host cleanup (handle orphan images separately).
2026-06-30 07:07:12 -05:00
jason 5ec784ebe6 docker-build.yml: push :latest only + clean up host build images
Build and Push Docker Image / build (push) Successful in 1m27s
- drop per-commit :sha tag (registry stays one tag per repo)
- remove the built image and prune dangling layers after push so CI builds
  stop accumulating orphan images on the Unraid host
2026-06-29 23:07:39 -05:00
jason c4d3940353 Update docker-build.yml: run on forgerunner host label, fix doubled image path
Build and Push Docker Image / build (push) Successful in 1m31s
2026-06-29 22:58:04 -05:00
jason 109cd3af05 fix Dockerfile
Build and Push Docker Image / build (push) Successful in 14s
2026-05-27 22:39:22 -05:00
jason 6ce9788a6b 90 day rolloff fix
Build and Push Docker Image / build (push) Successful in 16s
2026-05-27 21:41:57 -05:00
jason 08401afd28 user auth documentation changes
Build and Push Docker Image / build (push) Successful in 16s
2026-05-27 09:12:45 -05:00
jason 97be2d2908 auth modal
Build and Push Docker Image / build (push) Successful in 18s
2026-05-27 09:07:23 -05:00
jason 2d4920bd15 backfill button and usage
Build and Push Docker Image / build (push) Successful in 16s
2026-05-19 09:29:41 -05:00
jason 6ddc09aa71 fixes
Build and Push Docker Image / build (push) Successful in 35s
2026-05-19 00:34:58 -05:00
jason e2c352d518 Fix math logic for timeline 2026-05-19 00:33:08 -05:00
jason ba2b631e23 documentation
Build and Push Docker Image / build (push) Successful in 6s
2026-05-11 13:28:02 -05:00
jason 3220ee70c4 financial data entry
Build and Push Docker Image / build (push) Successful in 1m4s
2026-05-11 12:04:34 -05:00
jason 5a2b581c71 Add .gitea/workflows/docker-build.yml 2026-05-11 11:48:51 -05:00
jason 3a0934bdc6 Merge pull request 'feat: custom violation types — persist, manage, and use in violation form' (#46) from claude/musing-bell into master
Reviewed-on: #46
2026-03-18 16:25:53 -05:00
jason 95d56b5018 feat: custom violation types — persist, manage, and use in violation form
Adds a full CRUD system for user-defined violation types stored in a new
violation_types table. Custom types appear in the violation dropdown alongside
hardcoded types, grouped by category, with ✦ marker and a green Custom badge.

- db/database.js: auto-migration adds violation_types table on startup
- server.js: GET/POST/PUT/DELETE /api/violation-types; type_key auto-generated
  as custom_<slug>; DELETE blocked if any violations reference the type
- ViolationTypeModal.jsx: create/edit modal with name, category (datalist
  autocomplete from existing categories), handbook chapter reference,
  description/reference text, fixed vs sliding point toggle, context field
  checkboxes; delete with usage guard
- ViolationForm.jsx: fetches custom types on mount; merges into dropdown via
  mergedGroups memo; resolveViolation() checks hardcoded then custom; '+ Add
  Type' button above dropdown; 'Edit Type' button appears when a custom type is
  selected; newly created type auto-selects; all audit calls flow through
  existing audit() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:23:21 -05:00
jason 1c4494dd28 Merge pull request 'docs: add AGENTS.md with coding, compliance, and architecture guidance' (#45) from claude/musing-bell into master
Reviewed-on: #45
2026-03-18 16:15:54 -05:00
jason 563c5043c6 docs: add AGENTS.md with coding, compliance, and architecture guidance
Establishes AI agent and developer guidelines covering data integrity rules
(immutable scoring fields, soft-delete contract, audit log append-only),
tier system canonical source, migration patterns, coding standards for both
backend and frontend, schema change checklist, PDF generation notes, and
forward-thinking development constraints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:14:19 -05:00
jason 0c057ef0e4 Merge pull request 'Code review and fixes' (#44) from cpas-checking into master
Reviewed-on: #44
2026-03-18 16:01:40 -05:00
jason da36edbba6 vscode 2026-03-11 22:58:53 -05:00
jason d6585c01c6 Code review and fixes 2026-03-11 16:33:38 -05:00
69 changed files with 11237 additions and 203 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"permissions": {
"allow": [
"Bash(node --check server.js)",
"Bash(env)",
"Bash(findstr /I OBSIDIAN)",
"Bash(set)",
"Bash(xargs grep -l -i \"obsidian\")",
"Bash(xargs -I {} git log --oneline -1 {})",
"Bash(ls -la data)",
"Bash(ls *.db)",
"Bash(find . -name \"cpas.db\" -not -path \"*/node_modules/*\")",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")",
"Bash(node --check auth.js)",
"Bash(node --check db/database.js)",
"Bash(node -e ' *)",
"Bash(node --check lib/rolloff.js)",
"Bash(git -C F:/CODING/cpas/cpas add .)",
"Bash(git -C F:/CODING/cpas/cpas status)"
]
}
}
@@ -0,0 +1,9 @@
node_modules
client/node_modules
client/dist
*.tar.gz
*.zip
.git
.gitignore
*.md
data/
+294
View File
@@ -0,0 +1,294 @@
# AGENTS.md — CPAS Violation Tracker
Developer and AI agent guidance for working on this codebase. Read this before making changes.
---
## Project Purpose
CPAS (Corrective & Progressive Accountability System) is an internal HR tool for documenting employee violations, managing disciplinary tier escalation via a rolling 90-day point system, and producing auditable PDF records. It is a single-container Docker app deployed on a trusted internal network.
**This is a compliance tool.** Data integrity, auditability, and reversibility are first-class concerns. Every architectural decision below exists for a reason.
---
## Stack at a Glance
| Layer | Tech |
|---|---|
| Frontend | React 18 + Vite (SPA, served statically by Express) |
| Backend | Node.js + Express (REST API, `server.js`) |
| Database | SQLite via `better-sqlite3` (synchronous, WAL mode, FK enforcement) |
| PDF | Puppeteer + system Chromium (Alpine-bundled in Docker) |
| Styling | Inline React style objects; `client/src/styles/mobile.css` for breakpoints only |
| Deploy | Docker multi-stage build (Alpine); single container + volume mount at `/data` |
---
## Repository Layout
```
cpas/
├── Dockerfile # Multi-stage: builder (Node+React) → production (Alpine+Chromium)
├── server.js # All API routes + audit helper; single Express entry point
├── db/
│ ├── schema.sql # Base table + view definitions (CREATE TABLE IF NOT EXISTS)
│ └── database.js # DB connection, WAL/FK pragmas, auto-migrations on startup
├── pdf/
│ ├── generator.js # Puppeteer launcher; --no-sandbox for Docker
│ └── template.js # HTML template builder; loads logo from disk
├── demo/ # Static stakeholder demo page served at /demo
│ └── index.html # Synthetic data, no live API calls; registered before SPA catch-all
├── client/
│ ├── vite.config.js
│ ├── src/
│ │ ├── App.jsx # Root component + AppFooter
│ │ ├── main.jsx # React DOM mount
│ │ ├── data/
│ │ │ ├── violations.js # Canonical violation type registry (type key → metadata)
│ │ │ └── departments.js # DEPARTMENTS constant; single source of truth
│ │ ├── hooks/
│ │ │ └── useEmployeeIntelligence.js # Score + history fetch hook
│ │ ├── components/ # One file per component; no barrel index
│ │ └── styles/
│ │ └── mobile.css # Media query overrides only; all other styles are inline
└── README.md / README_UNRAID_INSTALL.md
```
---
## Data Model & Compliance Rules
### Tables
| Table | Purpose |
|---|---|
| `employees` | id, name, department, supervisor, notes |
| `violations` | Full incident record; contains immutable scoring fields |
| `violation_resolutions` | Soft-delete records (resolution type, reason, resolver) |
| `violation_amendments` | Field-level diff per amendment (old → new, changed_by, timestamp) |
| `audit_log` | Append-only write action log; never delete from this table |
| `active_cpas_scores` | VIEW: SUM(points) for negated=0 AND incident_date >= 90 days |
### Immutable Fields (DO NOT allow amendment of these)
The following fields on `violations` are locked after submission. They are the basis for tier calculation and PDF accuracy. **Never expose them to amendment endpoints:**
- `points`
- `violation_type`
- `violation_name`
- `category`
- `incident_date`
- `prior_active_points` (snapshot at insert time)
- `prior_tier_label`
Amendable fields (non-scoring): `location`, `details`, `witness_name`, `acknowledged_by`, `acknowledged_date`
### Soft-Delete Pattern
Violations are **never hard-deleted** in normal workflow. Use the `negated` flag + `violation_resolutions` record. Hard delete is reserved for confirmed data-entry errors and requires explicit user confirmation in the UI.
### Prior-Points Snapshot
Every `INSERT` into `violations` must compute and store `prior_active_points` (the employee's current active score before this violation is added). This snapshot ensures PDFs always reflect the accurate historical tier state regardless of subsequent negate/restore actions.
### Audit Log
Every write action (employee created/edited/merged, violation logged/amended/negated/restored/deleted) must call the `audit()` helper in `server.js`. Never skip audit calls on write routes. The audit log is append-only — no UPDATE or DELETE against `audit_log`.
---
## CPAS Tier System
These thresholds are the authoritative values. Any feature touching tiers must use them.
| Points | Tier | Label |
|---|---|---|
| 04 | 0-1 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
The canonical tier logic lives in `client/src/components/CpasBadge.jsx` (`TIERS` array, `getTier()`, `getNextTier()`). Do not duplicate this logic elsewhere — import from `CpasBadge`.
The 90-day rolling window is computed by the `active_cpas_scores` view. This view is **dropped and recreated** in `database.js` on every startup to ensure it always reflects the correct `negated=0` filter.
---
## Violation Type Registry
All violation types are defined in `client/src/data/violations.js` as `violationData`. Each entry includes:
```js
{
name: string, // Display name
category: string, // Grouping for UI display
minPoints: number, // Slider minimum
maxPoints: number, // Slider maximum (min === max means fixed, no slider)
chapter: string, // Policy chapter reference
fields: string[], // Which context fields to show ('time', 'minutes', 'amount', 'location', 'description')
description: string, // Plain-language definition shown in UI
}
```
To add a new violation type: add an entry to `violationData` with a unique camelCase key. Do not add new categories without confirming with the project owner — categories appear in UI groupings.
---
## Coding Standards
### Backend (`server.js`)
- Use `better-sqlite3` synchronous API. No async DB calls. This is intentional — it simplifies route handlers and matches Express's sync error handling.
- All prepared statements use positional `?` parameters. Never interpolate user input into SQL strings.
- Every POST/PUT/PATCH/DELETE route must:
1. Validate required inputs and return `400` with a descriptive `{ error: '...' }` body on failure.
2. Call `audit()` on success.
3. Return `{ error: '...' }` (not HTML) on all error paths.
- Group routes by resource (Employees, Violations, Dashboard, Audit). Match the existing comment banner style: `// ── Resource Name ───`.
- Do not add authentication middleware. This runs on a trusted internal network by design.
### Frontend (React)
- **Styling**: Use inline style objects defined as a `const s = { ... }` block at the top of each component file. Do not add CSS classes or CSS modules — except for responsive breakpoints which go in `mobile.css`.
- **Data constants**: Import violation types from `../data/violations`, departments from `../data/departments`, tier logic from `./CpasBadge`. Do not hardcode these values in components.
- **Toasts**: Use `useToast()` from `ToastProvider` for all user-facing feedback. Do not use `alert()` or `console.log` for user messages.
- **HTTP**: Use `axios` (already imported in form/modal components). Do not introduce `fetch` unless there is a compelling reason — keep it consistent.
- **State**: Prefer local `useState` over lifting state unless data is needed by multiple unrelated components. The only global context is `ToastProvider`.
- **Mobile**: Test layout at 768px breakpoint. Use the `isMobile` media query pattern already in `Dashboard.jsx` / `DashboardMobile.jsx`. Add breakpoint rules to `mobile.css`, not inline styles.
- **Component files**: One component per file. Name the file to match the export. No barrel `index.js` files.
### Database Migrations
New columns are added via the auto-migration pattern in `database.js`. Do not modify `schema.sql` for columns that already exist in production. Instead:
```js
// Example: adding a new column to violations
const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name);
if (!cols.includes('new_column')) db.exec("ALTER TABLE violations ADD COLUMN new_column TEXT");
```
Add a comment describing the feature the column enables. `schema.sql` is only for base tables — use it only for brand-new tables.
---
## Schema Changes: Decision Checklist
Before adding a column or table, answer:
1. **Does it affect scoring?** If yes, it must be immutable after insert and included in `prior_active_points` computation logic.
2. **Does it need audit trail?** If it tracks a change to an existing record, add a corresponding entry pattern to `violation_amendments` or `audit_log`.
3. **Is it soft-deletable?** Prefer `negated`/flag patterns over hard deletes for anything HR might need to reverse.
4. **Does it appear on PDFs?** Update `pdf/template.js` to reflect it. Test PDF output after schema changes.
5. **Does `active_cpas_scores` view need updating?** If the new column affects point calculations, update the view recreation block in `database.js`.
---
## PDF Generation
- PDFs are generated on-demand via `GET /api/violations/:id/pdf`. No pre-caching.
- Template is built in `pdf/template.js`. It receives the full violation + employee record. Logo is loaded from disk at startup and embedded as base64.
- Puppeteer launches with `--no-sandbox --disable-setuid-sandbox` (required for Docker; safe in this deployment context).
- Acknowledgment rendering: if `acknowledged_by` is set, show name + date in signature block. If not, render blank wet-ink signature lines.
- After any schema change that adds user-visible fields, update the template to include the new field where appropriate.
---
## Development Workflow
### Local Development (without Docker)
```bash
# Terminal 1 — backend
npm install
node server.js # Serves API on :3001 and client/dist statically
# Terminal 2 — frontend (hot reload)
cd client
npm install
npm run dev # Vite dev server on :5173 (proxy to :3001 configured in vite.config.js)
```
### Build & Deploy
```bash
# Build Docker image (compiles React inside container)
docker build -t cpas .
# Run (local)
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
# Unraid: build → save → transfer → load → run with --pids-limit 2048
# See README_UNRAID_INSTALL.md for full Unraid instructions
```
**Unraid PID limit is critical.** Chromium spawns many child processes for PDF generation. Always include `--pids-limit 2048` on Unraid containers or PDF generation will fail silently.
### Health Check
`GET /api/health` returns `{ status: 'ok', timestamp, version }`. The `version` field is populated by the Dockerfile at build time from git commit SHA. In local dev it returns `{ sha: 'dev' }` — this is expected.
---
## Forward-Thinking Development Guidelines
### Adding New Features
- **Score-affecting logic belongs in SQL**, not JavaScript. The `active_cpas_scores` view is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add a new SQL view — don't compute it in a route handler.
- **New violation fields**: Add to `schema.sql` for fresh installs AND to the migration block in `database.js` for existing databases. Both are required.
- **Reporting features**: Future aggregate queries should join against `active_cpas_scores` view and `audit_log` rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace.
- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or query `active_cpas_scores` — do not add side effects directly into violation insert routes.
- **Authentication**: If auth is ever added, implement it as Express middleware applied globally before all `/api` routes. Do not add per-route auth checks. Session data (user identity) should flow into `performed_by` fields on audit and amendment records.
- **Multi-tenant / multi-site**: The schema is single-tenant. If site isolation is ever needed, add a `site_id` foreign key to `employees` and `violations` as a migration column, then scope all queries with a `WHERE site_id = ?` clause.
### What NOT to Do
- Do not compute active CPAS scores in JavaScript by summing violations client-side. Always fetch from the `active_cpas_scores` view.
- Do not modify `prior_active_points` after a violation is inserted. It is a historical snapshot, not a live value.
- Do not add columns to `audit_log`. It is append-only with a fixed schema.
- Do not add a framework or ORM. Raw SQL with prepared statements is intentional — it keeps the query behavior explicit and the dependency surface small.
- Do not add a build step beyond `vite build`. The backend is plain CommonJS `require()`; do not transpile it.
- Do not use `alert()`, `console.log` for user messages, or `document.querySelector` inside React components.
---
## Documentation Standards
### Code Comments
- Comment **why**, not **what**. If the reason for a decision is not obvious from the code, explain it.
- Use the existing banner style for section groupings in `server.js`:
```js
// ── Section Name ─────────────────────────────────────────────────────────────
```
- Mark non-obvious schema columns with inline SQL comments (see `schema.sql` for examples).
- When adding a migration block, include a comment naming the feature it enables.
### In-App Documentation
The `ReadmeModal.jsx` component renders an admin reference panel accessible via the `? Docs` button. When adding a significant new feature:
- Add it to the feature map section of the docs modal.
- Update the tier system table if thresholds change.
- Move completed roadmap items from the "Proposed" section to the "Completed" section.
### README
Update `README.md` when:
- A new environment variable is introduced.
- The Docker run command changes (new volume, port, or flag).
- A new top-level feature is added that HR administrators need to know about.
Do not add implementation details to README — that belongs in code comments or AGENTS.md.
---
## Constraints & Non-Goals
- **No authentication.** This is intentional. The app runs on a trusted LAN. Do not add auth without explicit direction from the project owner.
- **No external dependencies beyond what's in `package.json`.** Avoid introducing new npm packages unless they solve a clearly scoped problem. Prefer using existing stack capabilities.
- **No client-side routing library.** Navigation between Violation Form, Dashboard, and modals is handled via `App.jsx` state (`view` prop). Do not introduce React Router unless the navigation model meaningfully grows beyond 34 views.
- **No test suite currently.** If adding tests, use Vitest for frontend and a lightweight assertion library for backend routes. Do not add a full testing framework without discussion.
- **SQLite only.** Do not introduce Postgres, Redis, or other datastores. The single-file DB on a Docker volume is the correct solution for this scale.
+39
View File
@@ -0,0 +1,39 @@
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json ./
RUN npm install
COPY client/package.json ./client/
RUN cd client && npm install
COPY client/ ./client/
RUN cd client && npm run build
# ── Version metadata ──────────────────────────────────────────────────────────
# Pass these at build time:
# docker build --build-arg GIT_SHA=$(git rev-parse HEAD) \
# --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) .
ARG GIT_SHA=dev
ARG BUILD_TIME=unknown
RUN echo "{\"sha\":\"${GIT_SHA}\",\"shortSha\":\"${GIT_SHA:0:7}\",\"buildTime\":\"${BUILD_TIME}\"}" \
> /build/client/dist/version.json
FROM node:20-alpine AS production
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 --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/client/dist ./client/dist
COPY server.js ./
COPY package.json ./
COPY db/ ./db/
COPY pdf/ ./pdf/
COPY demo/ ./demo/
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
CMD ["node", "server.js"]
+397
View File
@@ -0,0 +1,397 @@
# CPAS Violation Tracker
Single-container Dockerized web app for CPAS violation documentation and workforce standing management.
Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation).
> © Jason Stedwell · [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas)
---
## The only requirement on your machine: Docker Desktop
Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker.
---
## Quickstart (Local)
```bash
# 1. Build the image (installs all deps + compiles React inside Docker)
docker build -t cpas .
# 2. Run it
docker run -d --name cpas \
-p 3001:3001 \
-v cpas-data:/data \
cpas
# 3. Open
# http://localhost:3001
```
## Update After Code Changes
```bash
docker build -t cpas .
docker stop cpas && docker rm cpas
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
```
---
## Deploying on Unraid
### Step 1 — Build and export the image on your dev machine
```bash
docker build -t cpas:latest .
docker save cpas:latest | gzip > cpas-latest.tar.gz
```
### Step 2 — Load the image on Unraid
Transfer `cpas-latest.tar.gz` to your Unraid server, then load it via the Unraid terminal:
```bash
docker load < /path/to/cpas-latest.tar.gz
```
Confirm the image is present:
```bash
docker images | grep cpas
```
### Step 3 — Create the appdata directory
```bash
mkdir -p /mnt/user/appdata/cpas/db
```
### Step 4 — Run the container
This is the verified working `docker run` command for Unraid (bridge networking with static IP):
```bash
docker run \
-d \
--name='cpas' \
--net='br0' \
--ip='10.2.0.14' \
--pids-limit 2048 \
-e TZ="America/Chicago" \
-e HOST_OS="Unraid" \
-e HOST_HOSTNAME="ALPHA" \
-e HOST_CONTAINERNAME="cpas" \
-e 'PORT'='3001' \
-e 'DB_PATH'='/data/cpas.db' \
-l net.unraid.docker.managed=dockerman \
-l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \
-v '/mnt/user/appdata/cpas/db':'/data':'rw' \
cpas:latest
```
Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
### Key settings explained
| Setting | Value | Notes |
|---------|-------|-------|
| `--net` | `br0` | Unraid custom bridge network — gives the container its own LAN IP |
| `--ip` | `10.2.0.14` | Static IP on your LAN — adjust to match your subnet |
| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures |
| `PORT` | `3001` | Express listen port inside the container |
| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container |
| Volume | `/mnt/user/appdata/cpas/db``/data` | Persists the database across container restarts and rebuilds |
### Updating on Unraid
1. Build and export the new image on your dev machine (Step 1 above)
2. Load it on Unraid: `docker load < cpas-latest.tar.gz`
3. Stop and remove the old container: `docker stop cpas && docker rm cpas`
4. Re-run the `docker run` command from Step 4 — the volume mount preserves all data
> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container.
---
## Stakeholder Demo
A standalone demo page with synthetic data is available at `/demo` (e.g. `http://localhost:3001/demo`).
It is served as a static route before the SPA catch-all and requires no authentication.
Useful for showing the app to stakeholders without exposing live employee data.
---
## Features
### Company Dashboard
- Live table of all employees sorted by active CPAS points (highest risk first)
- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score
- **At-risk badge**: flags employees within 2 points of the next tier escalation
- Search/filter by name, department, or supervisor
- **Department filter**: pre-loaded dropdown of all departments for quick scoped views
- Click any employee name to open their full profile modal
- **📋 Audit Log** button — filterable, paginated view of all system write actions
### Violation Form
- Select existing employee or enter new employee by name
- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting
- Violation type dropdown grouped by category; shows prior 90-day counts inline
- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy
- Repeat offense badge with prior count displayed
- Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type
- **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission
- Point slider for discretionary adjustments within the violation's min/max range
- **Employee Acknowledgment section**: optional "received by employee" name and date fields; when filled, the PDF signature block shows the recorded acknowledgment instead of a blank signature line
- One-click PDF download immediately after submission
- **Toast notifications**: success/error/warning feedback for form submissions, validation, and PDF downloads
### Employee Profile Modal
- Full violation history with resolution status and **amendment count badge** per record
- **✎ Edit Employee** button — update name, department, supervisor, or notes inline
- **Merge Duplicate** tab — reassign all violations from a duplicate record and delete it
- **Amend** button per active violation — edit non-scoring fields (location, notes, witness, acknowledgment, etc.) with a full field-level diff history
- Negate / restore individual violations (soft delete with resolution type + notes)
- Hard delete option for data entry errors
- PDF download for any historical violation record
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators
- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit
### Audit Log
- Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted
- Filterable by entity type (employee / violation) and action
- Paginated with load-more; accessible from the Dashboard toolbar
### Violation Amendment
- Edit submitted violations' non-scoring fields without delete-and-resubmit
- Point values, violation type, and incident date are immutable
- Every change is stored as a field-level diff (old → new value) with timestamp and actor
### In-App Documentation
- **? Docs** button in the navbar opens a slide-in admin reference panel
- Covers feature map, CPAS tier system, workflow guidance, and roadmap
- No external link required; always reflects current deployed version
### Toast Notification System
- Global toast notifications for all user actions across the application
- Four variants: success (green), error (red), warning (gold), info (blue)
- Auto-dismiss with configurable duration and visual progress bar countdown
- Slide-in animation; stacks up to 5 notifications simultaneously
- Consistent dark theme styling matching the rest of the UI
### App Footer
- **© Jason Stedwell** copyright with auto-advancing year
- **Live dev ticker**: real-time elapsed counter since first commit (`2026-03-06`), ticking every second in `Xd HHh MMm SSs` format with a pulsing green dot
- **Gitea repo link** with icon — links directly to `git.alwisp.com/jason/cpas`
### CPAS Tier System
| Points | Tier | Label |
|--------|------|-------|
| 04 | 01 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
Scores are computed over a **rolling 90-day window** (negated violations excluded).
### PDF Generation
- Puppeteer + system Chromium (bundled in Docker image)
- Logo loaded from disk at startup (no hardcoded base64); falls back gracefully if not found
- Generated on-demand per violation via `GET /api/violations/:id/pdf`
- Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf`
- PDF captures prior active points **at the time of the incident** (snapshot stored on insert)
- **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing
---
## API Reference
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/employees` | List all employees (includes `notes`) |
| POST | `/api/employees` | Create or upsert employee |
| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes |
| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations |
| GET | `/api/employees/:id/score` | Get active CPAS score for employee |
| GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining |
| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) |
| GET | `/api/dashboard` | All employees with active points + violation counts |
| POST | `/api/violations` | Log a new violation (accepts `acknowledged_by`, `acknowledged_date`) |
| GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts |
| PATCH | `/api/violations/:id/negated` | Negate a violation (soft delete + resolution record) |
| PATCH | `/api/violations/:id/restore` | Restore a negated violation |
| PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging |
| GET | `/api/violations/:id/amendments` | Get amendment history for a violation |
| DELETE | `/api/violations/:id` | Hard delete a violation |
| GET | `/api/violations/:id/pdf` | Download violation PDF |
| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) |
---
## Project Structure
```
cpas/
├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium
├── .dockerignore
├── package.json # Backend (Express) deps
├── server.js # API + static file server
├── db/
│ ├── schema.sql # Tables + 90-day active score view
│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations
├── pdf/
│ ├── generator.js # Puppeteer PDF generation
│ └── template.js # HTML template (loads logo from disk, ack signature rendering)
├── demo/ # Static stakeholder demo page (served at /demo)
└── client/ # React frontend (Vite)
├── package.json
├── vite.config.js
├── index.html
└── src/
├── main.jsx
├── App.jsx # Root app + AppFooter (copyright, dev ticker, Gitea link)
├── data/
│ └── violations.js # All CPAS violation definitions + groups
├── hooks/
│ └── useEmployeeIntelligence.js # Score + history hook
└── components/
├── CpasBadge.jsx # Tier badge + color logic
├── TierWarning.jsx # Pre-submit tier crossing alert
├── Dashboard.jsx # Company-wide leaderboard + audit log trigger
├── ViolationForm.jsx # Violation entry form + ack signature fields
├── EmployeeModal.jsx # Employee profile + history modal
├── EditEmployeeModal.jsx # Employee edit + merge duplicate
├── AmendViolationModal.jsx # Non-scoring field amendment + diff history
├── AuditLog.jsx # Filterable audit log panel
├── NegateModal.jsx # Negate/resolve violation dialog
├── ViolationHistory.jsx # Violation list component
├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown
├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags
├── ToastProvider.jsx # Global toast notification system + useToast hook
└── ReadmeModal.jsx # In-app admin documentation panel
```
---
## Database Schema
Six tables + one view:
- **`employees`** — id, name, department, supervisor, **notes**
- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging, `acknowledged_by` and `acknowledged_date` for employee acknowledgment
- **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations)
- **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment
- **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp)
- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee
---
## Amendable Fields
Point values, violation type, and incident date are **immutable** after submission. The following fields can be amended:
| Field | Notes |
|-------|-------|
| `incident_time` | Time of day the incident occurred |
| `location` | Where the incident took place |
| `details` | Narrative description |
| `submitted_by` | Supervisor who submitted |
| `witness_name` | Witness on record |
| `acknowledged_by` | Employee who acknowledged receipt |
| `acknowledged_date` | Date of employee acknowledgment |
---
## Roadmap
### ✅ Completed
| Phase | Feature | Description |
|-------|---------|-------------|
| 1 | Container scaffold | Docker multi-stage build, Express server, SQLite schema |
| 1 | Base violation form | Employee fields, violation type, incident date, point submission |
| 2 | Employee intelligence | Live CPAS standing badge and 90-day count shown before submitting |
| 2 | Prior violation highlighting | Violation dropdown annotates types with 90-day recurrence counts |
| 2 | Recidivist auto-escalation | Points slider auto-maximizes on repeat same-type violations |
| 2 | Violation history | Per-employee history list with resolution status |
| 3 | PDF generation | Puppeteer/Chromium PDF per violation, downloadable immediately post-submit |
| 3 | Prior-points snapshot | `prior_active_points` captured at insert time for accurate historical PDFs |
| 4 | Company dashboard | Sortable employee table with live tier badges and at-risk flags |
| 4 | Stat cards | Summary counts: total, clean, active, at-risk, highest score |
| 4 | Tier crossing warning | Pre-submit alert when new points push employee to next tier |
| 4 | Employee profile modal | Full history, negate/restore, hard delete, per-record PDF download |
| 4 | Negate & restore | Soft-delete violations with resolution type + notes, fully reversible |
| 5 | Employee edit / merge | Update employee name/dept/supervisor; merge duplicate records without losing history |
| 5 | Violation amendment | Edit non-scoring fields with field-level audit trail |
| 5 | Audit log | Append-only log of all system writes; filterable panel in the dashboard |
| 6 | Employee notes / flags | Free-text notes on employee record with quick-add HR tags; does not affect scoring |
| 6 | Point expiration timeline | Per-violation roll-off countdown with tier-drop projections |
| 6 | In-app documentation | Admin usage guide and feature map accessible from the navbar |
| 7 | Acknowledgment signature field | "Received by employee" name + date on the violation form; renders on the PDF replacing blank signature lines with recorded acknowledgment |
| 7 | Toast notification system | Global success/error/warning/info notifications for all user actions; auto-dismiss with progress bar; consistent dark theme |
| 7 | Department dropdown | Pre-loaded select on the violation form replacing free-text department input; shared `DEPARTMENTS` constant |
| 8 | Stakeholder demo page | Standalone `/demo` route with synthetic data; static HTML served before SPA catch-all; useful for non-live presentations |
| 8 | App footer | Copyright (© Jason Stedwell), live dev ticker since first commit, Gitea repo icon+link |
---
### 📋 Proposed
Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High
#### Quick Wins (High value, low effort)
| Feature | Effort | Description |
|---------|--------|-------------|
| Column sort on dashboard | 🟢 | Click `Tier`, `Active Points`, or `Department` headers to sort in-place; one `useState` + comparator, no API changes |
| Department filter on dashboard | 🟢 | Multi-select dropdown to scope the employee table by department; `DEPARTMENTS` constant already exists |
| Keyboard shortcut: New Violation | 🟢 | `N` key triggers tab switch to the violation form; ~5 lines of code |
#### Reporting & Analytics
| Feature | Effort | Description |
|---------|--------|-------------|
| Violation trend chart | 🟡 | Line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns |
| Department heat map | 🟡 | Grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk |
| Violation sparklines per employee | 🟡 | Tiny inline bar chart of points over the last 6 months in the employee modal |
#### Employee Management
| Feature | Effort | Description |
|---------|--------|-------------|
| Supervisor scoped view | 🟡 | Dashboard filtered to a supervisor's direct reports, accessible via URL param (`?supervisor=Name`); no schema changes required |
| Employee photo / avatar | 🟢 | Optional avatar upload stored alongside the employee record; shown in the profile modal and dashboard row |
#### Violation Workflow
| Feature | Effort | Description |
|---------|--------|-------------|
| Draft / pending violations | 🟡 | Save a violation as draft before finalizing; useful when incidents need review before being officially logged |
| Violation templates | 🟢 | Pre-fill the form with a saved violation type + common details for frequently logged incidents |
#### Notifications & Escalation
| Feature | Effort | Description |
|---------|--------|-------------|
| Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed |
| At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var |
| version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` |
#### Infrastructure & Ops
| Feature | Effort | Description |
|---------|--------|-------------|
| Multi-user auth | 🔴 | Simple login with role-based access (admin, supervisor, read-only); currently the app runs on a trusted internal network with no auth |
| Automated DB backup | 🟡 | Cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule |
| Dark/light theme toggle | 🟡 | The UI is currently dark-only; a toggle would improve usability in bright environments |
---
*Proposed features are suggestions based on common HR documentation workflows. Priority and implementation order should be driven by actual operational needs.*
@@ -0,0 +1,17 @@
<!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>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
#root { height: 100%; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,5 @@
{
"sha": "dev",
"shortSha": "dev",
"buildTime": null
}
@@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react';
import ViolationForm from './components/ViolationForm';
import Dashboard from './components/Dashboard';
import ReadmeModal from './components/ReadmeModal';
import ToastProvider from './components/ToastProvider';
import './styles/mobile.css';
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
function elapsed(from) {
const totalSec = Math.floor((Date.now() - from.getTime()) / 1000);
const d = Math.floor(totalSec / 86400);
const h = Math.floor((totalSec % 86400) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
}
function DevTicker() {
const [tick, setTick] = useState(() => elapsed(PROJECT_START));
useEffect(() => {
const id = setInterval(() => setTick(elapsed(PROJECT_START)), 1000);
return () => clearInterval(id);
}, []);
return (
<span title="Time since first commit" style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
<span style={{
width: '7px', height: '7px', borderRadius: '50%',
background: '#22c55e', display: 'inline-block',
animation: 'cpas-pulse 1.4s ease-in-out infinite',
}} />
{tick}
</span>
);
}
function GiteaIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ verticalAlign: 'middle' }}>
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
</svg>
);
}
function AppFooter({ version }) {
const year = new Date().getFullYear();
const sha = version?.shortSha || null;
const built = version?.buildTime
? new Date(version.buildTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<>
<style>{`
@keyframes cpas-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
/* Mobile-specific footer adjustments */
@media (max-width: 768px) {
.footer-content {
flex-wrap: wrap;
justify-content: center;
font-size: 10px;
padding: 10px 16px;
gap: 8px;
}
}
`}</style>
<footer style={sf.footer} className="footer-content">
<span style={sf.copy}>&copy; {year} Jason Stedwell</span>
<span style={sf.sep}>&middot;</span>
<DevTicker />
<span style={sf.sep}>&middot;</span>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer" style={sf.link}>
<GiteaIcon /> cpas
</a>
{sha && sha !== 'dev' && (
<>
<span style={sf.sep}>&middot;</span>
<a
href={`${REPO_URL}/commit/${version.sha}`}
target="_blank"
rel="noopener noreferrer"
style={sf.link}
title={built ? `Built ${built}` : 'View commit'}
>
{sha}
</a>
</>
)}
</footer>
</>
);
}
const tabs = [
{ id: 'dashboard', label: '📊 Dashboard' },
{ id: 'violation', label: '+ New Violation' },
];
// Responsive utility hook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const s = {
app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' },
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',
}),
docsBtn: {
marginLeft: 'auto',
background: 'none',
border: '1px solid #2a2b3a',
color: '#9ca0b8',
borderRadius: '6px',
padding: '6px 14px',
fontSize: '12px',
cursor: 'pointer',
fontWeight: 600,
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '6px',
},
main: { flex: 1 },
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
};
// Mobile-responsive style overrides
const mobileStyles = `
@media (max-width: 768px) {
.app-nav {
padding: 0 16px !important;
flex-wrap: wrap;
justify-content: center;
}
.logo-wrap {
margin-right: 0 !important;
padding: 12px 0 !important;
width: 100%;
justify-content: center;
border-bottom: 1px solid #1a1b22;
}
.nav-tabs {
display: flex;
width: 100%;
justify-content: space-around;
}
.nav-tab {
flex: 1;
text-align: center;
padding: 14px 8px !important;
font-size: 13px !important;
}
.docs-btn {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 10px !important;
font-size: 11px !important;
}
.docs-btn span:first-child {
display: none;
}
.main-card {
margin: 12px !important;
border-radius: 8px !important;
}
}
@media (max-width: 480px) {
.logo-text {
font-size: 16px !important;
}
.logo-img {
height: 24px !important;
}
}
`;
const sf = {
footer: {
borderTop: '1px solid #1a1b22',
padding: '12px 40px',
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '11px',
color: 'rgba(248,249,250,0.35)',
background: '#000',
flexShrink: 0,
},
copy: { color: 'rgba(248,249,250,0.35)' },
sep: { color: 'rgba(248,249,250,0.15)' },
link: {
color: 'rgba(248,249,250,0.35)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
transition: 'color 0.15s',
},
};
export default function App() {
const [tab, setTab] = useState('dashboard');
const [showReadme, setShowReadme] = useState(false);
const [version, setVersion] = useState(null);
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => {
fetch('/version.json')
.then(r => r.ok ? r.json() : null)
.then(v => { if (v) setVersion(v); })
.catch(() => {});
}, []);
return (
<ToastProvider>
<style>{mobileStyles}</style>
<div style={s.app}>
<nav style={s.nav} className="app-nav">
<div style={s.logoWrap} className="logo-wrap">
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} className="logo-img" />
<div style={s.logoText} className="logo-text">CPAS Tracker</div>
</div>
<div className="nav-tabs">
{tabs.map(t => (
<button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
</button>
))}
</div>
<button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
<span>?</span> Docs
</button>
</nav>
<div style={s.main}>
<div style={s.card} className="main-card">
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div>
</div>
<AppFooter version={version} />
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
</div>
</ToastProvider>
);
}
@@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const FIELD_LABELS = {
incident_time: 'Incident Time',
location: 'Location / Context',
details: 'Incident Notes',
submitted_by: 'Submitted By',
witness_name: 'Witness / Documenting Officer',
};
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
},
modal: {
background: '#111217', color: '#f8f9fa', width: '520px', maxWidth: '95vw',
maxHeight: '90vh', overflowY: 'auto',
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
border: '1px solid #222',
},
header: {
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #222', position: 'sticky', top: 0, zIndex: 10,
},
headerLeft: {},
title: { fontSize: '15px', fontWeight: 700 },
subtitle: { fontSize: '11px', color: '#9ca0b8', marginTop: '2px' },
closeBtn: {
background: 'none', border: 'none', color: 'white', fontSize: '20px',
cursor: 'pointer', lineHeight: 1,
},
body: { padding: '22px' },
notice: {
background: '#0e1a30', border: '1px solid #1e3a5f', borderRadius: '6px',
padding: '10px 14px', fontSize: '12px', color: '#7eb8f7', marginBottom: '18px',
},
label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' },
input: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box',
},
textarea: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box', minHeight: '80px', resize: 'vertical',
},
divider: { borderTop: '1px solid #1c1d29', margin: '16px 0' },
sectionTitle: {
fontSize: '11px', fontWeight: 700, color: '#9ca0b8', textTransform: 'uppercase',
letterSpacing: '0.5px', marginBottom: '12px',
},
amendRow: {
background: '#0d0e14', border: '1px solid #1c1d29', borderRadius: '6px',
padding: '10px 12px', marginBottom: '8px', fontSize: '12px',
},
amendField: { fontWeight: 700, color: '#c0c2d6', marginBottom: '4px' },
amendOld: { color: '#ff7070', textDecoration: 'line-through', marginRight: '6px' },
amendNew: { color: '#9ef7c1' },
amendMeta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' },
btn: (color, bg) => ({
padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px',
cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none',
}),
error: {
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px',
},
};
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
}
export default function AmendViolationModal({ violation, onClose, onSaved }) {
const [fields, setFields] = useState({
incident_time: violation.incident_time || '',
location: violation.location || '',
details: violation.details || '',
submitted_by: violation.submitted_by || '',
witness_name: violation.witness_name || '',
});
const [changedBy, setChangedBy] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [amendments, setAmendments] = useState([]);
useEffect(() => {
axios.get(`/api/violations/${violation.id}/amendments`)
.then(r => setAmendments(r.data))
.catch(() => {});
}, [violation.id]);
const hasChanges = Object.entries(fields).some(
([k, v]) => v !== (violation[k] || '')
);
const handleSave = async () => {
setError('');
setSaving(true);
try {
// Only send fields that actually changed
const patch = Object.fromEntries(
Object.entries(fields).filter(([k, v]) => v !== (violation[k] || ''))
);
await axios.patch(`/api/violations/${violation.id}/amend`, { ...patch, changed_by: changedBy || null });
onSaved();
onClose();
} catch (e) {
setError(e.response?.data?.error || 'Failed to save amendment');
} finally {
setSaving(false);
}
};
const set = (field, value) => setFields(prev => ({ ...prev, [field]: value }));
return (
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={s.modal}>
<div style={s.header}>
<div style={s.headerLeft}>
<div style={s.title}>Amend Violation</div>
<div style={s.subtitle}>
CPAS-{String(violation.id).padStart(5, '0')} · {violation.violation_name} · {violation.incident_date}
</div>
</div>
<button style={s.closeBtn} onClick={onClose}></button>
</div>
<div style={s.body}>
<div style={s.notice}>
Only non-scoring fields can be amended. Point values, violation type, and incident date
are immutable delete and re-submit if those need to change.
</div>
{error && <div style={s.error}>{error}</div>}
{Object.entries(FIELD_LABELS).map(([field, label]) => (
<div key={field}>
<div style={s.label}>{label}</div>
{field === 'details' ? (
<textarea
style={s.textarea}
value={fields[field]}
onChange={e => set(field, e.target.value)}
/>
) : (
<input
style={s.input}
value={fields[field]}
onChange={e => set(field, e.target.value)}
/>
)}
</div>
))}
<div style={s.label}>Your Name (recorded in amendment log)</div>
<input
style={s.input}
value={changedBy}
onChange={e => setChangedBy(e.target.value)}
placeholder="Optional but recommended"
/>
<div style={s.row}>
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
<button
style={s.btn('#fff', hasChanges ? '#667eea' : '#333')}
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? 'Saving…' : 'Save Amendment'}
</button>
</div>
{amendments.length > 0 && (
<>
<div style={s.divider} />
<div style={s.sectionTitle}>Amendment History ({amendments.length})</div>
{amendments.map(a => (
<div key={a.id} style={s.amendRow}>
<div style={s.amendField}>{FIELD_LABELS[a.field_name] || a.field_name}</div>
<div>
<span style={s.amendOld}>{a.old_value || '(empty)'}</span>
<span style={{ color: '#555', marginRight: '6px' }}></span>
<span style={s.amendNew}>{a.new_value || '(empty)'}</span>
</div>
<div style={s.amendMeta}>
{a.changed_by ? `by ${a.changed_by} · ` : ''}{fmtDt(a.created_at)}
</div>
</div>
))}
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,200 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const ACTION_COLORS = {
employee_created: '#667eea',
employee_edited: '#9b8af8',
employee_merged: '#f0a500',
violation_created: '#28a745',
violation_amended: '#4db6ac',
violation_negated: '#ffc107',
violation_restored:'#17a2b8',
violation_deleted: '#dc3545',
};
const ACTION_LABELS = {
employee_created: 'Employee Created',
employee_edited: 'Employee Edited',
employee_merged: 'Employee Merged',
violation_created: 'Violation Logged',
violation_amended: 'Violation Amended',
violation_negated: 'Violation Negated',
violation_restored:'Violation Restored',
violation_deleted: 'Violation Deleted',
};
const ENTITY_LABELS = {
employee: 'Employee',
violation: 'Violation',
};
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
},
panel: {
background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw',
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
display: 'flex', flexDirection: 'column',
},
header: {
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
padding: '22px 26px', position: 'sticky', top: 0, zIndex: 10,
borderBottom: '1px solid #222',
},
headerRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
title: { fontSize: '17px', fontWeight: 700 },
subtitle: { fontSize: '12px', color: '#9ca0b8', marginTop: '3px' },
closeBtn: {
background: 'none', border: 'none', color: 'white', fontSize: '22px',
cursor: 'pointer', lineHeight: 1,
},
filters: {
padding: '14px 26px', borderBottom: '1px solid #1c1d29',
display: 'flex', gap: '10px', flexWrap: 'wrap',
},
select: {
background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '7px 12px', fontSize: '12px', outline: 'none',
},
body: { padding: '16px 26px', flex: 1 },
entry: {
borderBottom: '1px solid #1c1d29', padding: '12px 0',
display: 'flex', gap: '12px', alignItems: 'flex-start',
},
dot: (action) => ({
width: '8px', height: '8px', borderRadius: '50%', marginTop: '5px', flexShrink: 0,
background: ACTION_COLORS[action] || '#555',
}),
entryMain: { flex: 1, minWidth: 0 },
actionBadge: (action) => ({
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', marginRight: '6px',
background: (ACTION_COLORS[action] || '#555') + '22',
color: ACTION_COLORS[action] || '#aaa',
border: `1px solid ${(ACTION_COLORS[action] || '#555')}44`,
}),
entityRef: { fontSize: '11px', color: '#9ca0b8' },
details: { fontSize: '11px', color: '#667', marginTop: '4px', fontFamily: 'monospace', wordBreak: 'break-all' },
meta: { fontSize: '10px', color: '#555a7a', marginTop: '4px' },
empty: { textAlign: 'center', color: '#555a7a', padding: '60px 0', fontSize: '13px' },
loadMore: {
width: '100%', background: 'none', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#9ca0b8', padding: '10px', cursor: 'pointer', fontSize: '12px', marginTop: '16px',
},
};
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short',
});
}
function renderDetails(detailsStr) {
if (!detailsStr) return null;
try {
const obj = JSON.parse(detailsStr);
return JSON.stringify(obj, null, 0)
.replace(/^\{/, '').replace(/\}$/, '').replace(/","/g, ' ');
} catch {
return detailsStr;
}
}
export default function AuditLog({ onClose }) {
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [filterType, setFilterType] = useState('');
const [filterAction, setFilterAction] = useState('');
const LIMIT = 50;
const load = useCallback((reset = false) => {
setLoading(true);
const o = reset ? 0 : offset;
const params = { limit: LIMIT, offset: o };
if (filterType) params.entity_type = filterType;
if (filterAction) params.action = filterAction; // future: server-side action filter
axios.get('/api/audit', { params })
.then(r => {
const data = r.data;
// Client-side action filter (cheap enough at this scale)
const filtered = filterAction ? data.filter(e => e.action === filterAction) : data;
setEntries(prev => reset ? filtered : [...prev, ...filtered]);
setHasMore(data.length === LIMIT);
setOffset(o + LIMIT);
})
.finally(() => setLoading(false));
}, [offset, filterType, filterAction]);
useEffect(() => { load(true); }, [filterType, filterAction]); // eslint-disable-line
const handleOverlay = e => { if (e.target === e.currentTarget) onClose(); };
return (
<div style={s.overlay} onClick={handleOverlay}>
<div style={s.panel} onClick={e => e.stopPropagation()}>
<div style={s.header}>
<div style={s.headerRow}>
<div>
<div style={s.title}>Audit Log</div>
<div style={s.subtitle}>All system write actions append-only</div>
</div>
<button style={s.closeBtn} onClick={onClose}></button>
</div>
</div>
<div style={s.filters}>
<select style={s.select} value={filterType} onChange={e => { setFilterType(e.target.value); setOffset(0); }}>
<option value="">All entity types</option>
{Object.entries(ENTITY_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<select style={s.select} value={filterAction} onChange={e => { setFilterAction(e.target.value); setOffset(0); }}>
<option value="">All actions</option>
{Object.entries(ACTION_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div style={s.body}>
{loading && entries.length === 0 ? (
<div style={s.empty}>Loading</div>
) : entries.length === 0 ? (
<div style={s.empty}>No audit entries found.</div>
) : (
entries.map(e => (
<div key={e.id} style={s.entry}>
<div style={s.dot(e.action)} />
<div style={s.entryMain}>
<div>
<span style={s.actionBadge(e.action)}>
{ACTION_LABELS[e.action] || e.action}
</span>
<span style={s.entityRef}>
{ENTITY_LABELS[e.entity_type] || e.entity_type}
{e.entity_id ? ` #${e.entity_id}` : ''}
</span>
</div>
{e.details && (
<div style={s.details}>{renderDetails(e.details)}</div>
)}
<div style={s.meta}>
{e.performed_by ? `by ${e.performed_by} · ` : ''}{fmtDt(e.created_at)}
</div>
</div>
</div>
))
)}
{hasMore && (
<button style={s.loadMore} onClick={() => load(false)}>
Load more
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import React from 'react';
const TIERS = [
{ min: 0, max: 4, label: 'Tier 0-1 — Elite Standing', color: '#28a745', bg: '#d4edda' },
{ min: 5, max: 9, label: 'Tier 1 — Realignment', color: '#856404', bg: '#fff3cd' },
{ min: 10, max: 14, label: 'Tier 2 — Administrative Lockdown', color: '#d9534f', bg: '#f8d7da' },
{ min: 15, max: 19, label: 'Tier 3 — Verification', color: '#d9534f', bg: '#f8d7da' },
{ min: 20, max: 24, label: 'Tier 4 — Risk Mitigation', color: '#721c24', bg: '#f5c6cb' },
{ min: 25, max: 29, label: 'Tier 5 — Final Decision', color: '#721c24', bg: '#f5c6cb' },
{ min: 30, max: 999,label: 'Tier 6 — Separation', color: '#fff', bg: '#721c24' },
];
export function getTier(points) {
return TIERS.find(t => points >= t.min && points <= t.max) || TIERS[0];
}
export function getNextTier(points) {
const idx = TIERS.findIndex(t => points >= t.min && points <= t.max);
return idx >= 0 && idx < TIERS.length - 1 ? TIERS[idx + 1] : null;
}
export default function CpasBadge({ points }) {
const tier = getTier(points);
return (
<span style={{
display: 'inline-block',
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 700,
color: tier.color,
background: tier.bg,
border: `1px solid ${tier.color}`,
}}>
{points} pts {tier.label}
</span>
);
}
@@ -0,0 +1,349 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import CpasBadge, { getTier } from './CpasBadge';
import EmployeeModal from './EmployeeModal';
import AuditLog from './AuditLog';
import DashboardMobile from './DashboardMobile';
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 },
];
function nextTierBoundary(points) {
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;
}
// Media query hook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
// Filter keys
const FILTER_NONE = null;
const FILTER_TOTAL = 'total';
const FILTER_ELITE = 'elite';
const FILTER_ACTIVE = 'active';
const FILTER_AT_RISK = 'at_risk';
const s = {
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 #303136', borderRadius: '8px', padding: '16px', textAlign: 'center', cursor: 'pointer', transition: 'border-color 0.15s, box-shadow 0.15s' },
statCardActive: { boxShadow: '0 0 0 2px #d4af37', border: '1px solid #d4af37' },
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
filterBadge: { fontSize: '10px', color: '#d4af37', marginTop: '4px', fontWeight: 600 },
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' },
toolbarRight: { display: 'flex', gap: '10px', alignItems: 'center' },
refreshBtn: { padding: '9px 18px', background: '#d4af37', color: '#000', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
auditBtn: { padding: '9px 18px', background: 'none', color: '#9ca0b8', border: '1px solid #2a2b3a', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
};
// Mobile styles
const mobileStyles = `
@media (max-width: 768px) {
.dashboard-wrap {
padding: 16px !important;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start !important;
}
.dashboard-title {
font-size: 20px !important;
}
.dashboard-subtitle {
font-size: 12px !important;
}
.dashboard-stats {
gap: 10px !important;
}
.dashboard-stat-card {
min-width: calc(50% - 5px) !important;
padding: 12px !important;
}
.stat-num {
font-size: 24px !important;
}
.stat-lbl {
font-size: 10px !important;
}
.toolbar-right {
width: 100%;
flex-direction: column;
}
.search-input {
width: 100% !important;
}
.toolbar-btn {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.dashboard-stat-card {
min-width: 100% !important;
}
}
`;
export default function Dashboard() {
const [employees, setEmployees] = useState([]);
const [filtered, setFiltered] = useState([]);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [showAudit, setShowAudit] = useState(false);
const [loading, setLoading] = useState(true);
const [activeFilter, setActiveFilter] = useState(FILTER_NONE);
const isMobile = useMediaQuery('(max-width: 768px)');
const load = useCallback(() => {
setLoading(true);
axios.get('/api/dashboard')
.then(r => { setEmployees(r.data); setFiltered(r.data); })
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// Apply search + badge filter together
useEffect(() => {
const q = search.toLowerCase();
let base = employees;
if (activeFilter === FILTER_ELITE) {
base = base.filter(e => e.active_points >= 0 && e.active_points <= 4);
} else if (activeFilter === FILTER_ACTIVE) {
base = base.filter(e => e.active_points > 0);
} else if (activeFilter === FILTER_AT_RISK) {
base = base.filter(e => isAtRisk(e.active_points));
}
// FILTER_TOTAL and FILTER_NONE show all
if (q) {
base = base.filter(e =>
e.name.toLowerCase().includes(q) ||
(e.department || '').toLowerCase().includes(q) ||
(e.supervisor || '').toLowerCase().includes(q)
);
}
setFiltered(base);
}, [search, employees, activeFilter]);
const atRiskCount = employees.filter(e => isAtRisk(e.active_points)).length;
const activeCount = employees.filter(e => e.active_points > 0).length;
// Elite Standing: 04 pts (Tier 0-1)
const eliteCount = employees.filter(e => e.active_points >= 0 && e.active_points <= 4).length;
const maxPoints = employees.reduce((m, e) => Math.max(m, e.active_points), 0);
function handleBadgeClick(filterKey) {
setActiveFilter(prev => prev === filterKey ? FILTER_NONE : filterKey);
}
function cardStyle(filterKey, extra = {}) {
const isActive = activeFilter === filterKey;
return {
...s.statCard,
...(isActive ? s.statCardActive : {}),
...extra,
};
}
return (
<>
<style>{mobileStyles}</style>
<div style={s.wrap} className="dashboard-wrap">
<div style={s.header} className="dashboard-header">
<div>
<div style={s.title} className="dashboard-title">Company Dashboard</div>
<div style={s.subtitle} className="dashboard-subtitle">
Click any employee name to view their full profile
{activeFilter && activeFilter !== FILTER_NONE && (
<span style={{ marginLeft: '10px', color: '#d4af37', fontWeight: 600 }}>
· Filtered: {activeFilter === FILTER_ELITE ? 'Elite Standing (04 pts)' : activeFilter === FILTER_ACTIVE ? 'With Active Points' : activeFilter === FILTER_AT_RISK ? 'At Risk' : 'All'}
<button
onClick={() => setActiveFilter(FILTER_NONE)}
style={{ marginLeft: '6px', background: 'none', border: 'none', color: '#9ca0b8', cursor: 'pointer', fontSize: '12px' }}
title="Clear filter"
></button>
</span>
)}
</div>
</div>
<div style={s.toolbarRight} className="toolbar-right">
<input
style={s.search}
className="search-input"
placeholder="Search name, dept, supervisor…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<button style={s.auditBtn} className="toolbar-btn" onClick={() => setShowAudit(true)}>📋 Audit Log</button>
<button style={s.refreshBtn} className="toolbar-btn" onClick={load}> Refresh</button>
</div>
</div>
<div style={s.statsRow} className="dashboard-stats">
{/* Total Employees — clicking shows all */}
<div
style={cardStyle(FILTER_TOTAL)}
className="dashboard-stat-card"
onClick={() => handleBadgeClick(FILTER_TOTAL)}
title="Click to show all employees"
>
<div style={s.statNum} className="stat-num">{employees.length}</div>
<div style={s.statLbl} className="stat-lbl">Total Employees</div>
{activeFilter === FILTER_TOTAL && <div style={s.filterBadge}> Showing All</div>}
</div>
{/* Elite Standing: 04 pts */}
<div
style={cardStyle(FILTER_ELITE, { borderTop: '3px solid #28a745' })}
className="dashboard-stat-card"
onClick={() => handleBadgeClick(FILTER_ELITE)}
title="Click to filter: Elite Standing (04 pts)"
>
<div style={{ ...s.statNum, color: '#6ee7b7' }} className="stat-num">{eliteCount}</div>
<div style={s.statLbl} className="stat-lbl">Elite Standing (04 pts)</div>
{activeFilter === FILTER_ELITE && <div style={s.filterBadge}> Filtered</div>}
</div>
{/* With Active Points */}
<div
style={cardStyle(FILTER_ACTIVE, { borderTop: '3px solid #d4af37' })}
className="dashboard-stat-card"
onClick={() => handleBadgeClick(FILTER_ACTIVE)}
title="Click to filter: employees with active points"
>
<div style={{ ...s.statNum, color: '#ffd666' }} className="stat-num">{activeCount}</div>
<div style={s.statLbl} className="stat-lbl">With Active Points</div>
{activeFilter === FILTER_ACTIVE && <div style={s.filterBadge}> Filtered</div>}
</div>
{/* At Risk */}
<div
style={cardStyle(FILTER_AT_RISK, { borderTop: '3px solid #ffb020' })}
className="dashboard-stat-card"
onClick={() => handleBadgeClick(FILTER_AT_RISK)}
title={`Click to filter: at risk (≤${AT_RISK_THRESHOLD} pts to next tier)`}
>
<div style={{ ...s.statNum, color: '#ffdf8a' }} className="stat-num">{atRiskCount}</div>
<div style={s.statLbl} className="stat-lbl">At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div>
{activeFilter === FILTER_AT_RISK && <div style={s.filterBadge}> Filtered</div>}
</div>
{/* Highest Score — display only, no filter */}
<div
style={{ ...s.statCard, borderTop: '3px solid #c0392b', cursor: 'default' }}
className="dashboard-stat-card"
>
<div style={{ ...s.statNum, color: '#ff8a80' }} className="stat-num">{maxPoints}</div>
<div style={s.statLbl} className="stat-lbl">Highest Active Score</div>
</div>
</div>
{loading ? (
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading</p>
) : isMobile ? (
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>#</th>
<th style={s.th}>Employee</th>
<th style={s.th}>Department</th>
<th style={s.th}>Supervisor</th>
<th style={s.th}>Tier / Standing</th>
<th style={s.th}>Active Points</th>
<th style={s.th}>90-Day Violations</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={7} style={{ ...s.td, textAlign: 'center', ...s.zeroRow }}>
No employees found.
</td>
</tr>
)}
{filtered.map((emp, i) => {
const risk = isAtRisk(emp.active_points);
const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points);
return (
<tr
key={emp.id}
style={{ background: risk ? '#181200' : i % 2 === 0 ? '#111217' : '#151622' }}
>
<td style={{ ...s.td, color: '#77798a', fontSize: '12px' }}>{i + 1}</td>
<td style={s.td}>
<button style={s.nameBtn} onClick={() => setSelectedId(emp.id)}>
{emp.name}
</button>
{risk && (
<span style={s.atRiskBadge}>
{boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
</span>
)}
</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.department || '—'}</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.supervisor || '—'}</td>
<td style={s.td}><CpasBadge points={emp.active_points} /></td>
<td style={{ ...s.td, fontWeight: 700, color: tier.color, fontSize: '16px' }}>
{emp.active_points}
</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{emp.violation_count}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{selectedId && (
<EmployeeModal
employeeId={selectedId}
onClose={() => { setSelectedId(null); load(); }}
/>
)}
{showAudit && <AuditLog onClose={() => setShowAudit(false)} />}
</>
);
}
@@ -0,0 +1,157 @@
import React from 'react';
import CpasBadge, { getTier } from './CpasBadge';
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 },
];
function nextTierBoundary(points) {
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 s = {
card: {
background: '#181924',
border: '1px solid #2a2b3a',
borderRadius: '10px',
padding: '16px',
marginBottom: '12px',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
},
cardAtRisk: {
background: '#181200',
border: '1px solid #d4af37',
},
row: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
},
rowLast: {
borderBottom: 'none',
},
label: {
fontSize: '11px',
fontWeight: 600,
color: '#9ca0b8',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
value: {
fontSize: '14px',
fontWeight: 600,
color: '#f8f9fa',
textAlign: 'right',
},
name: {
fontSize: '16px',
fontWeight: 700,
color: '#d4af37',
marginBottom: '8px',
cursor: 'pointer',
textDecoration: 'underline dotted',
background: 'none',
border: 'none',
padding: 0,
textAlign: 'left',
width: '100%',
},
atRiskBadge: {
display: 'inline-block',
marginTop: '4px',
padding: '3px 8px',
borderRadius: '10px',
fontSize: '10px',
fontWeight: 700,
background: '#3b2e00',
color: '#ffd666',
border: '1px solid #d4af37',
},
points: {
fontSize: '28px',
fontWeight: 800,
textAlign: 'center',
margin: '8px 0',
},
};
export default function DashboardMobile({ employees, onEmployeeClick }) {
if (!employees || employees.length === 0) {
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#77798a', fontStyle: 'italic' }}>
No employees found.
</div>
);
}
return (
<div style={{ padding: '12px' }}>
{employees.map((emp) => {
const risk = isAtRisk(emp.active_points);
const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points);
const cardStyle = risk ? { ...s.card, ...s.cardAtRisk } : s.card;
return (
<div key={emp.id} style={cardStyle}>
<button style={s.name} onClick={() => onEmployeeClick(emp.id)}>
{emp.name}
</button>
{risk && (
<div style={s.atRiskBadge}>
{boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
</div>
)}
<div style={{ ...s.row, marginTop: '12px' }}>
<span style={s.label}>Tier / Standing</span>
<span style={s.value}><CpasBadge points={emp.active_points} /></span>
</div>
<div style={s.row}>
<span style={s.label}>Active Points</span>
<span style={{ ...s.points, color: tier.color }}>{emp.active_points}</span>
</div>
<div style={s.row}>
<span style={s.label}>90-Day Violations</span>
<span style={s.value}>{emp.violation_count}</span>
</div>
{emp.department && (
<div style={s.row}>
<span style={s.label}>Department</span>
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.department}</span>
</div>
)}
{emp.supervisor && (
<div style={{ ...s.row, ...s.rowLast }}>
<span style={s.label}>Supervisor</span>
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.supervisor}</span>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { DEPARTMENTS } from '../data/departments';
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center',
},
modal: {
background: '#111217', color: '#f8f9fa', width: '480px', maxWidth: '95vw',
borderRadius: '10px', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
border: '1px solid #222', overflow: 'hidden',
},
header: {
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
padding: '18px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #222',
},
title: { fontSize: '15px', fontWeight: 700 },
closeBtn: {
background: 'none', border: 'none', color: 'white', fontSize: '20px',
cursor: 'pointer', lineHeight: 1,
},
body: { padding: '22px' },
tabs: { display: 'flex', gap: '4px', marginBottom: '20px' },
tab: (active) => ({
flex: 1, padding: '8px', borderRadius: '6px', cursor: 'pointer', fontSize: '12px',
fontWeight: 700, textAlign: 'center', border: '1px solid',
background: active ? '#1a1c2e' : 'none',
borderColor: active ? '#667eea' : '#2a2b3a',
color: active ? '#667eea' : '#777',
}),
label: { fontSize: '11px', color: '#9ca0b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '5px' },
input: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box',
},
select: {
width: '100%', background: '#0d0e14', border: '1px solid #2a2b3a', borderRadius: '6px',
color: '#f8f9fa', padding: '9px 12px', fontSize: '13px', marginBottom: '14px',
outline: 'none', boxSizing: 'border-box',
},
row: { display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '6px' },
btn: (color, bg) => ({
padding: '8px 18px', borderRadius: '6px', fontWeight: 700, fontSize: '13px',
cursor: 'pointer', border: `1px solid ${color}`, color, background: bg || 'none',
}),
error: {
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
padding: '10px 12px', fontSize: '12px', color: '#ffb3b8', marginBottom: '14px',
},
success: {
background: '#0a2e1f', border: '1px solid #0f5132', borderRadius: '6px',
padding: '10px 12px', fontSize: '12px', color: '#9ef7c1', marginBottom: '14px',
},
mergeWarning: {
background: '#2a1f00', border: '1px solid #7a5000', borderRadius: '6px',
padding: '12px', fontSize: '12px', color: '#ffc107', marginBottom: '14px', lineHeight: 1.5,
},
};
export default function EditEmployeeModal({ employee, onClose, onSaved }) {
const [tab, setTab] = useState('edit');
// Edit state
const [name, setName] = useState(employee.name);
const [department, setDepartment] = useState(employee.department || '');
const [supervisor, setSupervisor] = useState(employee.supervisor || '');
const [editError, setEditError] = useState('');
const [editSaving, setEditSaving] = useState(false);
// Merge state
const [allEmployees, setAllEmployees] = useState([]);
const [sourceId, setSourceId] = useState('');
const [mergeError, setMergeError] = useState('');
const [mergeResult, setMergeResult] = useState(null);
const [merging, setMerging] = useState(false);
useEffect(() => {
if (tab === 'merge') {
axios.get('/api/employees').then(r => setAllEmployees(r.data));
}
}, [tab]);
const handleEdit = async () => {
setEditError('');
setEditSaving(true);
try {
await axios.patch(`/api/employees/${employee.id}`, { name, department, supervisor });
onSaved();
onClose();
} catch (e) {
setEditError(e.response?.data?.error || 'Failed to save changes');
} finally {
setEditSaving(false);
}
};
const handleMerge = async () => {
if (!sourceId) return setMergeError('Select an employee to merge in');
setMergeError('');
setMerging(true);
try {
const r = await axios.post(`/api/employees/${employee.id}/merge`, { source_id: parseInt(sourceId) });
setMergeResult(r.data);
onSaved(); // refresh dashboard / parent list
} catch (e) {
setMergeError(e.response?.data?.error || 'Merge failed');
} finally {
setMerging(false);
}
};
const otherEmployees = allEmployees.filter(e => e.id !== employee.id);
return (
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={s.modal}>
<div style={s.header}>
<div style={s.title}>Edit Employee</div>
<button style={s.closeBtn} onClick={onClose}></button>
</div>
<div style={s.body}>
<div style={s.tabs}>
<button style={s.tab(tab === 'edit')} onClick={() => setTab('edit')}>Edit Details</button>
<button style={s.tab(tab === 'merge')} onClick={() => setTab('merge')}>Merge Duplicate</button>
</div>
{tab === 'edit' && (
<>
{editError && <div style={s.error}>{editError}</div>}
<div style={s.label}>Full Name</div>
<input style={s.input} value={name} onChange={e => setName(e.target.value)} />
<div style={s.label}>Department</div>
<select style={s.select} value={department} onChange={e => setDepartment(e.target.value)}>
<option value="">-- Select Department --</option>
{DEPARTMENTS.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
<div style={s.label}>Supervisor</div>
<input style={s.input} value={supervisor} onChange={e => setSupervisor(e.target.value)} placeholder="Optional" />
<div style={s.row}>
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
<button style={s.btn('#fff', '#667eea')} onClick={handleEdit} disabled={editSaving}>
{editSaving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</>
)}
{tab === 'merge' && (
<>
{mergeResult ? (
<div style={s.success}>
Merge complete {mergeResult.violations_reassigned} violation{mergeResult.violations_reassigned !== 1 ? 's' : ''} reassigned
to <strong>{employee.name}</strong>. The duplicate record has been removed.
</div>
) : (
<>
<div style={s.mergeWarning}>
This will reassign <strong>all violations</strong> from the selected employee into{' '}
<strong>{employee.name}</strong>, then permanently delete the duplicate record.
This cannot be undone.
</div>
{mergeError && <div style={s.error}>{mergeError}</div>}
<div style={s.label}>Duplicate to merge into {employee.name}</div>
<select style={s.select} value={sourceId} onChange={e => setSourceId(e.target.value)}>
<option value=""> select employee </option>
{otherEmployees.map(e => (
<option key={e.id} value={e.id}>{e.name}{e.department ? ` (${e.department})` : ''}</option>
))}
</select>
<div style={s.row}>
<button style={s.btn('#888')} onClick={onClose}>Cancel</button>
<button style={s.btn('#fff', '#c0392b')} onClick={handleMerge} disabled={merging || !sourceId}>
{merging ? 'Merging…' : 'Merge & Delete Duplicate'}
</button>
</div>
</>
)}
{mergeResult && (
<div style={s.row}>
<button style={s.btn('#fff', '#667eea')} onClick={onClose}>Done</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,443 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import CpasBadge, { getTier } from './CpasBadge';
import NegateModal from './NegateModal';
import EditEmployeeModal from './EditEmployeeModal';
import AmendViolationModal from './AmendViolationModal';
import ExpirationTimeline from './ExpirationTimeline';
import EmployeeNotes from './EmployeeNotes';
import { useToast } from './ToastProvider';
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
zIndex: 1000, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end',
},
panel: {
background: '#111217', color: '#f8f9fa', width: '680px', maxWidth: '95vw',
height: '100vh', overflowY: 'auto', boxShadow: '-4px 0 24px rgba(0,0,0,0.7)',
display: 'flex', flexDirection: 'column',
},
header: {
background: 'linear-gradient(135deg, #000000, #151622)', color: 'white',
padding: '24px 28px', position: 'sticky', top: 0, zIndex: 10,
borderBottom: '1px solid #222',
},
headerRow: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' },
closeBtn: {
float: 'right', background: 'none', border: 'none', color: 'white',
fontSize: '22px', cursor: 'pointer', lineHeight: 1, marginTop: '-2px',
},
editEmpBtn: {
background: 'none', border: '1px solid #555', color: '#ccc', borderRadius: '5px',
padding: '4px 10px', fontSize: '11px', cursor: 'pointer', marginTop: '8px', fontWeight: 600,
},
body: { padding: '24px 28px', flex: 1 },
scoreRow: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '24px' },
scoreCard: {
flex: '1', minWidth: '100px', background: '#181924', borderRadius: '8px',
padding: '14px', textAlign: 'center', border: '1px solid #2a2b3a',
},
scoreNum: { fontSize: '26px', fontWeight: 800 },
scoreLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '3px' },
sectionHd: {
fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase',
letterSpacing: '0.5px', marginBottom: '10px', marginTop: '24px',
},
table: {
width: '100%', borderCollapse: 'collapse', fontSize: '12px', background: '#181924',
borderRadius: '6px', overflow: 'hidden', border: '1px solid #2a2b3a',
},
th: {
background: '#050608', padding: '8px 10px', textAlign: 'left', color: '#f8f9fa',
fontWeight: 600, fontSize: '11px', textTransform: 'uppercase',
},
td: {
padding: '9px 10px', borderBottom: '1px solid #202231',
verticalAlign: 'top', color: '#f8f9fa',
},
negatedRow: { background: '#151622', color: '#9ca0b8' },
actionBtn: (color) => ({
background: 'none', border: `1px solid ${color}`, color,
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
cursor: 'pointer', marginRight: '4px', fontWeight: 600,
}),
resTag: {
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
fontSize: '10px', fontWeight: 700, background: '#053321',
color: '#9ef7c1', border: '1px solid #0f5132',
},
pdfBtn: {
background: 'none', border: '1px solid #d4af37', color: '#ffd666',
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
cursor: 'pointer', fontWeight: 600,
},
amendBtn: {
background: 'none', border: '1px solid #4db6ac', color: '#4db6ac',
borderRadius: '4px', padding: '3px 8px', fontSize: '11px',
cursor: 'pointer', marginRight: '4px', fontWeight: 600,
},
deleteConfirm: {
background: '#3c1114', border: '1px solid #f5c6cb', borderRadius: '6px',
padding: '12px', marginTop: '8px', fontSize: '12px', color: '#ffb3b8',
},
amendBadge: {
display: 'inline-block', marginLeft: '4px', padding: '1px 5px', borderRadius: '8px',
fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac',
border: '1px solid #1a4a4a', verticalAlign: 'middle',
},
};
export default function EmployeeModal({ employeeId, onClose }) {
const [employee, setEmployee] = useState(null);
const [score, setScore] = useState(null);
const [violations, setViolations] = useState([]);
const [loading, setLoading] = useState(true);
const [negating, setNegating] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const [editingEmp, setEditingEmp] = useState(false);
const [amending, setAmending] = useState(null); // violation object
const toast = useToast();
const load = useCallback(() => {
setLoading(true);
Promise.all([
axios.get('/api/employees'),
axios.get(`/api/employees/${employeeId}/score`),
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
])
.then(([empRes, scoreRes, violRes]) => {
const emp = empRes.data.find((e) => e.id === employeeId);
setEmployee(emp || null);
setScore(scoreRes.data);
setViolations(violRes.data);
})
.finally(() => setLoading(false));
}, [employeeId]);
useEffect(() => { load(); }, [load]);
const handleDownloadPdf = async (violId, empName, date) => {
try {
const response = await axios.get(`/api/violations/${violId}/pdf`, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.download = `CPAS_${(empName || '').replace(/[^a-z0-9]/gi, '_')}_${date}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success('PDF downloaded.');
} catch (err) {
toast.error('PDF generation failed: ' + (err.response?.data?.error || err.message));
}
};
const handleHardDelete = async (id) => {
try {
await axios.delete(`/api/violations/${id}`);
toast.success('Violation permanently deleted.');
setConfirmDel(null);
load();
} catch (err) {
toast.error('Delete failed: ' + (err.response?.data?.error || err.message));
}
};
const handleRestore = async (id) => {
try {
await axios.patch(`/api/violations/${id}/restore`);
toast.success('Violation restored to active.');
setConfirmDel(null);
load();
} catch (err) {
toast.error('Restore failed: ' + (err.response?.data?.error || err.message));
}
};
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
try {
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
toast.success('Violation negated.');
setNegating(null);
setConfirmDel(null);
load();
} catch (err) {
toast.error('Negate failed: ' + (err.response?.data?.error || err.message));
}
};
const tier = score ? getTier(score.active_points) : null;
const active = violations.filter((v) => !v.negated);
const negated = violations.filter((v) => v.negated);
const handleOverlayClick = (e) => { if (e.target === e.currentTarget) onClose(); };
return (
<div style={s.overlay} onClick={handleOverlayClick}>
<div style={s.panel} onClick={(e) => e.stopPropagation()}>
{/* ── Header ── */}
<div style={s.header}>
<div style={s.headerRow}>
<div>
<div style={{ fontSize: '18px', fontWeight: 700 }}>
{employee ? employee.name : 'Employee'}
</div>
{employee && (
<div style={{ fontSize: '12px', color: '#b5b5c0', marginTop: '4px' }}>
{employee.department} {employee.supervisor && `· Supervisor: ${employee.supervisor}`}
</div>
)}
{employee && (
<button style={s.editEmpBtn} onClick={() => setEditingEmp(true)}>
Edit Employee
</button>
)}
</div>
<button style={s.closeBtn} onClick={onClose}></button>
</div>
</div>
{/* ── Body ── */}
<div style={s.body}>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#b5b5c0' }}>Loading</div>
) : (
<>
{/* Score Cards */}
{score && (
<div style={s.scoreRow}>
<div style={s.scoreCard}>
<div style={{ ...s.scoreNum, color: tier?.color || '#f8f9fa' }}>
{score.active_points}
</div>
<div style={s.scoreLbl}>Active Points</div>
</div>
<div style={s.scoreCard}>
<div style={s.scoreNum}>{score.total_violations}</div>
<div style={s.scoreLbl}>Total Violations</div>
</div>
<div style={s.scoreCard}>
<div style={s.scoreNum}>{score.negated_count}</div>
<div style={s.scoreLbl}>Negated</div>
</div>
<div style={{ ...s.scoreCard, minWidth: '140px' }}>
<div style={{ fontSize: '13px', fontWeight: 700, color: tier?.color || '#f8f9fa' }}>
{tier ? tier.label : '—'}
</div>
<div style={s.scoreLbl}>Current Tier</div>
</div>
</div>
)}
{score && <CpasBadge points={score.active_points} style={{ marginBottom: '20px' }} />}
{/* ── Employee Notes ── */}
{employee && (
<EmployeeNotes
employeeId={employeeId}
initialNotes={employee.notes}
onSaved={(notes) => setEmployee(prev => ({ ...prev, notes }))}
/>
)}
{/* ── Expiration Timeline ── */}
{score && score.active_points > 0 && (
<ExpirationTimeline
employeeId={employeeId}
currentPoints={score.active_points}
/>
)}
{/* ── Active Violations ── */}
<div style={s.sectionHd}>Active Violations</div>
{active.length === 0 ? (
<div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}>
No active violations on record.
</div>
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Date</th>
<th style={s.th}>Violation</th>
<th style={s.th}>Pts</th>
<th style={s.th}>Actions</th>
</tr>
</thead>
<tbody>
{active.map((v) => (
<tr key={v.id}>
<td style={s.td}>{v.incident_date}</td>
<td style={s.td}>
<div style={{ fontWeight: 600 }}>
{v.violation_name}
{v.amendment_count > 0 && (
<span style={s.amendBadge}>{v.amendment_count} edit{v.amendment_count !== 1 ? 's' : ''}</span>
)}
</div>
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
{v.details && (
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>{v.details}</div>
)}
</td>
<td style={{ ...s.td, fontWeight: 700 }}>{v.points}</td>
<td style={s.td}>
<button style={s.amendBtn} onClick={(e) => { e.stopPropagation(); setAmending(v); }}>
Amend
</button>
<button
style={s.actionBtn('#ffc107')}
onClick={(e) => { e.stopPropagation(); setNegating(v); setConfirmDel(null); }}
>
Negate
</button>
<button
style={s.actionBtn('#ff4d4f')}
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
>
{confirmDel === v.id ? 'Cancel' : 'Delete'}
</button>
<button
style={s.pdfBtn}
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
>
PDF
</button>
{confirmDel === v.id && (
<div style={s.deleteConfirm}>
Permanently delete? This cannot be undone.
<div style={{ marginTop: '8px' }}>
<button
style={s.actionBtn('#ff4d4f')}
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
>
Confirm Delete
</button>
<button
style={s.actionBtn('#888')}
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
>
Cancel
</button>
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* ── Negated / Resolved Violations ── */}
{negated.length > 0 && (
<>
<div style={s.sectionHd}>Negated / Resolved</div>
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Date</th>
<th style={s.th}>Violation</th>
<th style={s.th}>Pts</th>
<th style={s.th}>Resolution</th>
<th style={s.th}>Actions</th>
</tr>
</thead>
<tbody>
{negated.map((v) => (
<tr key={v.id} style={s.negatedRow}>
<td style={s.td}>{v.incident_date}</td>
<td style={s.td}>
<div style={{ fontWeight: 600 }}>{v.violation_name}</div>
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>{v.category}</div>
</td>
<td style={s.td}>{v.points}</td>
<td style={s.td}>
<span style={s.resTag}>{v.resolution_type}</span>
{v.resolution_details && (
<div style={{ fontSize: '10px', color: '#b5b5c0', marginTop: '2px' }}>
{v.resolution_details}
</div>
)}
{v.resolved_by && (
<div style={{ fontSize: '10px', color: '#9ca0b8' }}>by {v.resolved_by}</div>
)}
</td>
<td style={s.td}>
<button
style={s.actionBtn('#4db6ac')}
onClick={(e) => { e.stopPropagation(); handleRestore(v.id); }}
>
Restore
</button>
<button
style={s.actionBtn('#ff4d4f')}
onClick={(e) => { e.stopPropagation(); setConfirmDel(confirmDel === v.id ? null : v.id); }}
>
{confirmDel === v.id ? 'Cancel' : 'Delete'}
</button>
<button
style={s.pdfBtn}
onClick={(e) => { e.stopPropagation(); handleDownloadPdf(v.id, employee?.name, v.incident_date); }}
>
PDF
</button>
{confirmDel === v.id && (
<div style={s.deleteConfirm}>
Permanently delete? This cannot be undone.
<div style={{ marginTop: '8px' }}>
<button
style={s.actionBtn('#ff4d4f')}
onClick={(e) => { e.stopPropagation(); handleHardDelete(v.id); }}
>
Confirm Delete
</button>
<button
style={s.actionBtn('#888')}
onClick={(e) => { e.stopPropagation(); setConfirmDel(null); }}
>
Cancel
</button>
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</div>
</div>
{/* Modals rendered outside panel to avoid z-index nesting issues */}
{negating && (
<NegateModal
violation={negating}
onConfirm={handleNegate}
onCancel={() => setNegating(null)}
/>
)}
{editingEmp && employee && (
<EditEmployeeModal
employee={employee}
onClose={() => setEditingEmp(false)}
onSaved={() => { toast.success('Employee updated.'); load(); }}
/>
)}
{amending && (
<AmendViolationModal
violation={amending}
onClose={() => setAmending(null)}
onSaved={() => { toast.success('Violation amended.'); load(); }}
/>
)}
</div>
);
}
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import axios from 'axios';
const s = {
wrapper: { marginTop: '20px' },
sectionHd: {
fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase',
letterSpacing: '0.5px', marginBottom: '8px',
},
display: {
background: '#181924', border: '1px solid #2a2b3a', borderRadius: '6px',
padding: '10px 12px', fontSize: '13px', color: '#f8f9fa', minHeight: '36px',
cursor: 'pointer', position: 'relative',
},
displayEmpty: {
color: '#555770', fontStyle: 'italic',
},
editHint: {
position: 'absolute', right: '8px', top: '8px',
fontSize: '10px', color: '#555770',
},
textarea: {
width: '100%', background: '#0d1117', border: '1px solid #4d6fa8',
borderRadius: '6px', color: '#f8f9fa', fontSize: '13px',
padding: '10px 12px', resize: 'vertical', minHeight: '80px',
boxSizing: 'border-box', fontFamily: 'inherit', outline: 'none',
},
actions: { display: 'flex', gap: '8px', marginTop: '8px' },
saveBtn: {
background: '#1a3a6b', border: '1px solid #4d6fa8', color: '#90caf9',
borderRadius: '5px', padding: '5px 14px', fontSize: '12px',
cursor: 'pointer', fontWeight: 600,
},
cancelBtn: {
background: 'none', border: '1px solid #444', color: '#888',
borderRadius: '5px', padding: '5px 14px', fontSize: '12px',
cursor: 'pointer',
},
saving: { fontSize: '12px', color: '#9ca0b8', alignSelf: 'center' },
tagRow: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' },
tag: {
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
fontSize: '11px', fontWeight: 600, background: '#1a2a3a',
color: '#90caf9', border: '1px solid #2a3a5a', cursor: 'default',
},
};
// Quick-add tags for common HR flags
const QUICK_TAGS = ['On PIP', 'Union member', 'Probationary', 'Pending investigation', 'FMLA', 'ADA'];
export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initialNotes || '');
const [saved, setSaved] = useState(initialNotes || '');
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await axios.patch(`/api/employees/${employeeId}/notes`, { notes: draft });
setSaved(draft);
setEditing(false);
if (onSaved) onSaved(draft);
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setDraft(saved);
setEditing(false);
};
const addTag = (tag) => {
const current = draft.trim();
// Don't add a tag that's already present
if (current.includes(tag)) return;
setDraft(current ? `${current}\n${tag}` : tag);
};
// Parse saved notes into display lines
const lines = saved ? saved.split('\n').filter(Boolean) : [];
return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Notes &amp; Flags</div>
{!editing ? (
<div
style={s.display}
onClick={() => { setDraft(saved); setEditing(true); }}
title="Click to edit"
>
<span style={s.editHint}> edit</span>
{lines.length === 0 ? (
<span style={s.displayEmpty}>No notes click to add</span>
) : (
<div style={s.tagRow}>
{lines.map((line, i) => (
<span key={i} style={s.tag}>{line}</span>
))}
</div>
)}
</div>
) : (
<div>
{/* Quick-add tag buttons */}
<div style={{ ...s.tagRow, marginBottom: '6px' }}>
{QUICK_TAGS.map(tag => (
<button
key={tag}
style={{
...s.tag,
cursor: 'pointer',
background: draft.includes(tag) ? '#0e2a3a' : '#1a2a3a',
opacity: draft.includes(tag) ? 0.5 : 1,
}}
onClick={() => addTag(tag)}
title="Add tag"
>
+ {tag}
</button>
))}
</div>
<textarea
style={s.textarea}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="Free-text notes — one per line or comma-separated. Does not affect CPAS scoring."
autoFocus
/>
<div style={s.actions}>
<button style={s.saveBtn} onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Notes'}
</button>
<button style={s.cancelBtn} onClick={handleCancel} disabled={saving}>
Cancel
</button>
{saving && <span style={s.saving}>Saving</span>}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
// Tier thresholds used to compute what tier an employee would drop to
// after a given violation rolls off.
const TIER_THRESHOLDS = [
{ min: 30, label: 'Separation', color: '#ff1744' },
{ min: 25, label: 'Final Decision', color: '#ff6d00' },
{ min: 20, label: 'Risk Mitigation', color: '#ff9100' },
{ min: 15, label: 'Verification', color: '#ffc400' },
{ min: 10, label: 'Administrative Lockdown', color: '#ffea00' },
{ min: 5, label: 'Realignment', color: '#b2ff59' },
{ min: 0, label: 'Elite Standing', color: '#69f0ae' },
];
function getTier(pts) {
return TIER_THRESHOLDS.find(t => pts >= t.min) || TIER_THRESHOLDS[TIER_THRESHOLDS.length - 1];
}
function urgencyColor(days) {
if (days <= 7) return '#ff4d4f';
if (days <= 14) return '#ffa940';
if (days <= 30) return '#fadb14';
return '#52c41a';
}
const s = {
wrapper: { marginTop: '24px' },
sectionHd: {
fontSize: '13px', fontWeight: 700, color: '#f8f9fa', textTransform: 'uppercase',
letterSpacing: '0.5px', marginBottom: '10px',
},
empty: { color: '#777990', fontStyle: 'italic', fontSize: '12px' },
row: {
display: 'flex', alignItems: 'center', gap: '12px',
padding: '10px 12px', background: '#181924', borderRadius: '6px',
border: '1px solid #2a2b3a', marginBottom: '6px',
},
bar: (pct, color) => ({
flex: 1, height: '6px', background: '#2a2b3a', borderRadius: '3px', overflow: 'hidden',
position: 'relative',
}),
barFill: (pct, color) => ({
position: 'absolute', left: 0, top: 0, bottom: 0,
width: `${Math.min(100, Math.max(0, 100 - pct))}%`,
background: color, borderRadius: '3px',
transition: 'width 0.3s ease',
}),
pill: (color) => ({
display: 'inline-block', padding: '2px 8px', borderRadius: '10px',
fontSize: '11px', fontWeight: 700, background: `${color}22`,
color, border: `1px solid ${color}55`, whiteSpace: 'nowrap',
}),
pts: { fontSize: '13px', fontWeight: 700, color: '#f8f9fa', minWidth: '28px', textAlign: 'right' },
name: { fontSize: '12px', color: '#f8f9fa', fontWeight: 600, flex: '0 0 160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
date: { fontSize: '11px', color: '#9ca0b8', minWidth: '88px' },
projBox: {
marginTop: '16px', padding: '12px 14px', background: '#0d1117',
border: '1px solid #2a2b3a', borderRadius: '6px', fontSize: '12px', color: '#b5b5c0',
},
projRow: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px' },
};
export default function ExpirationTimeline({ employeeId, currentPoints }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
axios.get(`/api/employees/${employeeId}/expiration`)
.then(r => setItems(r.data))
.finally(() => setLoading(false));
}, [employeeId]);
if (loading) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={{ ...s.empty }}>Loading</div>
</div>
);
if (items.length === 0) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.empty}>No active violations nothing to expire.</div>
</div>
);
// Build running totals: after each violation expires, what's the remaining score?
let running = currentPoints || 0;
const projected = items.map(item => {
const before = running;
running = Math.max(0, running - item.points);
const tierBefore = getTier(before);
const tierAfter = getTier(running);
const dropped = tierAfter.min < tierBefore.min;
return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped };
});
return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
{projected.map((item) => {
const color = urgencyColor(item.days_remaining);
const pct = (item.days_remaining / 90) * 100;
return (
<div key={item.id} style={s.row}>
{/* Violation name */}
<div style={s.name} title={item.violation_name}>{item.violation_name}</div>
{/* Points badge */}
<div style={s.pts}>{item.points}</div>
{/* Progress bar: how much of the 90 days has elapsed */}
<div style={s.bar(pct, color)}>
<div style={s.barFill(pct, color)} />
</div>
{/* Days remaining pill */}
<div style={s.pill(color)}>
{item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`}
</div>
{/* Expiry date */}
<div style={s.date}>{item.expires_on}</div>
{/* Tier drop indicator */}
{item.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{item.tierAfter.label}
</div>
)}
</div>
);
})}
{/* Projection summary */}
<div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each expiration
</div>
{projected.map((item, i) => (
<div key={item.id} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{item.expires_on} {item.violation_name}</span>
<span>
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span>
{item.tierDropped && (
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}>
{item.tierAfter.label}
</span>
)}
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
import React, { useState } from 'react';
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 2000,
},
modal: {
width: '480px', maxWidth: '95vw', background: '#111217', borderRadius: '12px',
boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
overflow: 'hidden', border: '1px solid #2a2b3a',
},
header: {
padding: '18px 24px', borderBottom: '1px solid #222',
background: 'linear-gradient(135deg, #000000, #151622)',
},
title: { fontSize: '18px', fontWeight: 700 },
subtitle: { fontSize: '12px', color: '#c0c2d6', marginTop: '4px' },
body: { padding: '18px 24px 8px 24px' },
pill: {
background: '#3b2e00', borderRadius: '6px', padding: '8px 10px',
fontSize: '12px', color: '#ffd666', border: '1px solid #d4af37', marginBottom: '14px',
},
label: { fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
input: {
width: '100%', padding: '9px 10px', borderRadius: '6px',
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
fontSize: '13px', fontFamily: 'inherit', marginBottom: '14px',
boxSizing: 'border-box',
},
textarea: {
width: '100%', minHeight: '80px', resize: 'vertical',
padding: '9px 10px', borderRadius: '6px', border: '1px solid #333544',
background: '#050608', color: '#f8f9fa', fontSize: '13px',
fontFamily: 'inherit', marginBottom: '14px', boxSizing: 'border-box',
},
footer: {
display: 'flex', justifyContent: 'flex-end', gap: '10px',
padding: '16px 24px 20px 24px', background: '#0c0d14', borderTop: '1px solid #222',
},
btnCancel: {
padding: '10px 20px', borderRadius: '6px', border: '1px solid #333544',
background: '#050608', color: '#f8f9fa', fontWeight: 600,
fontSize: '13px', cursor: 'pointer',
},
btnConfirm: {
padding: '10px 22px', borderRadius: '6px', border: 'none',
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
color: '#000', fontWeight: 700, fontSize: '13px',
cursor: 'pointer', textTransform: 'uppercase',
},
};
const RESOLUTION_OPTIONS = [
'Corrective Training Completed',
'Verbal Warning Issued',
'Written Warning Issued',
'Management Review',
'Policy Exception Approved',
'Data Entry Error',
'Other',
];
export default function NegateModal({ violation, onConfirm, onCancel }) {
const [resolutionType, setResolutionType] = useState('Corrective Training Completed');
const [details, setDetails] = useState('');
const [resolvedBy, setResolvedBy] = useState('');
if (!violation) return null;
const handleConfirm = () => {
if (!onConfirm) return;
onConfirm({
resolution_type: resolutionType,
details,
resolved_by: resolvedBy,
});
};
// FIX: overlay click only closes on backdrop, NOT modal children
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget && onCancel) onCancel();
};
return (
<div style={s.overlay} onClick={handleOverlayClick}>
{/* FIX: stopPropagation prevents modal clicks from bubbling to overlay */}
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
<div style={s.header}>
<div style={s.title}>Negate Violation</div>
<div style={s.subtitle}>
Record resolution for: <strong>{violation.violation_name}</strong>
</div>
</div>
<div style={s.body}>
<div style={s.pill}>
{violation.points} pt{violation.points !== 1 ? 's' : ''} · {violation.incident_date} · {violation.category}
</div>
<div style={s.label}>Resolution Type</div>
<select
style={s.input}
value={resolutionType}
onChange={(e) => setResolutionType(e.target.value)}
>
{RESOLUTION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
<div style={s.label}>Details / Notes</div>
<textarea
style={s.textarea}
placeholder="Describe the resolution or context…"
value={details}
onChange={(e) => setDetails(e.target.value)}
/>
<div style={s.label}>Resolved By</div>
<input
style={s.input}
placeholder="Manager or HR name…"
value={resolvedBy}
onChange={(e) => setResolvedBy(e.target.value)}
/>
</div>
<div style={s.footer}>
<button style={s.btnCancel} onClick={onCancel}>Cancel</button>
<button style={s.btnConfirm} onClick={handleConfirm}>Confirm Negation</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,340 @@
import React, { useEffect, useRef } from 'react';
// Minimal Markdown to HTML renderer (headings, bold, inline-code, tables, hr, ul, ol, paragraphs)
function mdToHtml(md) {
const lines = md.split('\n');
const out = [];
let i = 0, inUl = false, inOl = false, inTable = false;
const close = () => {
if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; }
if (inTable) { out.push('</tbody></table>'); inTable = false; }
};
const inline = s =>
s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/`([^`]+)`/g,'<code>$1</code>');
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('```')) { close(); i++; while (i < lines.length && !lines[i].startsWith('```')) i++; i++; continue; }
if (/^---+$/.test(line.trim())) { close(); out.push('<hr>'); i++; continue; }
const hm = line.match(/^(#{1,4})\s+(.+)/);
if (hm) { close(); const lv=hm[1].length, id=hm[2].toLowerCase().replace(/[^a-z0-9]+/g,'-'); out.push(`<h${lv} id="${id}">${inline(hm[2])}</h${lv}>`); i++; continue; }
if (line.trim().startsWith('|')) {
const cells = line.trim().replace(/^\|||\|$/g,'').split('|').map(c=>c.trim());
if (!inTable) { close(); inTable=true; out.push('<table><thead><tr>'); cells.forEach(c=>out.push(`<th>${inline(c)}</th>`)); out.push('</tr></thead><tbody>'); i++; if (i < lines.length && /^[\|\s\:\-]+$/.test(lines[i])) i++; continue; }
else { out.push('<tr>'); cells.forEach(c=>out.push(`<td>${inline(c)}</td>`)); out.push('</tr>'); i++; continue; }
}
const ul = line.match(/^[-*]\s+(.*)/);
if (ul) { if (inTable) close(); if (!inUl) { if (inOl){out.push('</ol>');inOl=false;} out.push('<ul>');inUl=true; } out.push(`<li>${inline(ul[1])}</li>`); i++; continue; }
const ol = line.match(/^\d+\.\s+(.*)/);
if (ol) { if (inTable) close(); if (!inOl) { if (inUl){out.push('</ul>');inUl=false;} out.push('<ol>');inOl=true; } out.push(`<li>${inline(ol[1])}</li>`); i++; continue; }
if (line.trim() === '') { close(); i++; continue; }
close(); out.push(`<p>${inline(line)}</p>`); i++;
}
close();
return out.join('\n');
}
function buildToc(md) {
return md.split('\n').reduce((acc, line) => {
const m = line.match(/^(#{1,2})\s+(.+)/);
if (m) acc.push({ level: m[1].length, text: m[2], id: m[2].toLowerCase().replace(/[^a-z0-9]+/g,'-') });
return acc;
}, []);
}
// ——— Styles ——————————————————————————————————————————————————————————————————
const S = {
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:2000, display:'flex', alignItems:'flex-start', justifyContent:'flex-end' },
panel: { background:'#111217', color:'#f8f9fa', width:'760px', maxWidth:'95vw', height:'100vh', overflowY:'auto', boxShadow:'-4px 0 32px rgba(0,0,0,0.85)', display:'flex', flexDirection:'column' },
header: { background:'linear-gradient(135deg,#000000,#151622)', color:'white', padding:'22px 28px', position:'sticky', top:0, zIndex:10, borderBottom:'1px solid #222', display:'flex', alignItems:'center', justifyContent:'space-between' },
closeBtn:{ background:'none', border:'none', color:'white', fontSize:'22px', cursor:'pointer', lineHeight:1 },
toc: { background:'#0d1117', borderBottom:'1px solid #1e1f2e', padding:'10px 32px', display:'flex', flexWrap:'wrap', gap:'4px 18px', fontSize:'11px' },
body: { padding:'28px 32px', flex:1, fontSize:'13px', lineHeight:'1.75' },
footer: { padding:'14px 32px', borderTop:'1px solid #1e1f2e', fontSize:'11px', color:'#555770', textAlign:'center' },
};
const CSS = `
.adm h1 { font-size:21px; font-weight:800; color:#f8f9fa; margin:28px 0 10px; border-bottom:1px solid #2a2b3a; padding-bottom:8px }
.adm h2 { font-size:16px; font-weight:700; color:#d4af37; margin:28px 0 6px; letter-spacing:.2px }
.adm h3 { font-size:12px; font-weight:700; color:#90caf9; margin:18px 0 4px; text-transform:uppercase; letter-spacing:.5px }
.adm h4 { font-size:13px; font-weight:600; color:#b0b8d0; margin:14px 0 4px }
.adm p { color:#c8ccd8; margin:5px 0 10px }
.adm hr { border:none; border-top:1px solid #2a2b3a; margin:22px 0 }
.adm strong { color:#f8f9fa }
.adm code { background:#0d1117; color:#79c0ff; border:1px solid #2a2b3a; border-radius:4px; padding:1px 6px; font-family:'Consolas','Fira Code',monospace; font-size:12px }
.adm ul { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
.adm ol { padding-left:20px; margin:5px 0 10px; color:#c8ccd8 }
.adm li { margin:4px 0 }
.adm table { width:100%; border-collapse:collapse; font-size:12px; background:#181924; border-radius:6px; overflow:hidden; border:1px solid #2a2b3a; margin:10px 0 16px }
.adm th { background:#050608; padding:8px 12px; text-align:left; color:#f8f9fa; font-weight:600; font-size:11px; text-transform:uppercase; border-bottom:1px solid #2a2b3a }
.adm td { padding:8px 12px; border-bottom:1px solid #202231; color:#c8ccd8 }
.adm tr:last-child td { border-bottom:none }
.adm tr:hover td { background:#1e1f2e }
`;
// ——— Admin guide content (no install / Docker content) ————————————————————
const GUIDE_MD = `# CPAS Tracker — Admin Guide
Internal tool for CPAS violation documentation, workforce standing management, and audit compliance. All data is stored locally in the Docker container volume — there is no external dependency.
---
## How Scoring Works
Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
## Tier Reference
| Points | Tier | Label |
|--------|------|-------|
| 04 | 01 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
The **at-risk badge** on the dashboard flags anyone within 2 points of the next tier threshold so supervisors can act before escalation occurs.
---
## Feature Map
### Dashboard
The main view. Employees are sorted by active CPAS points, highest first.
- **Stat cards** — live counts: total employees, zero-point (elite), with active points, at-risk, highest score
- **Search / filter** — by name, department, or supervisor; narrows the table in real time
- **At-risk badge** — gold flag on rows where the employee is within 2 pts of the next tier
- **Audit Log button** — opens the filterable, paginated write-action log (top right of the dashboard toolbar)
- **Click any name** — opens that employee's full profile modal
---
### Logging a Violation
Use the **+ New Violation** tab.
1. Select an existing employee from the dropdown, or type a new name to create a record on-the-fly.
2. The **employee intelligence panel** loads their current tier badge and 90-day violation count before you commit.
3. Choose a violation type. The dropdown is grouped by category and shows prior 90-day counts inline for each type.
4. If the employee has a prior violation of the same type, the **recidivist auto-escalation** rule triggers — the points slider jumps to the maximum allowed for that violation type.
5. The **tier crossing warning** previews what tier the submission would land the employee in. Review before submitting.
6. Adjust points using the slider if discretionary reduction is warranted (within the violation's allowed min/max range).
7. **Employee Acknowledgment** (optional): if the employee is present and acknowledges receipt, enter their printed name and the acknowledgment date. This replaces the blank signature line on the PDF with a recorded acknowledgment and an "Acknowledged" badge. Leave blank if the employee is not present or declines.
8. Submit. A **PDF download link** appears immediately — download it for the employee's file.
9. **Toast notifications** confirm success or surface errors at the top right of the screen. Toasts auto-dismiss after a few seconds.
---
### Employee Profile Modal
Click any name on the dashboard to open their profile.
#### Overview section
Shows current tier badge, active points, and 90-day violation count.
#### Notes & Flags
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
#### Point Expiration Timeline
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
#### Violation History
Full record of all submissions — active, negated, and resolved.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
All actions trigger **toast notifications** confirming success or surfacing errors.
#### Edit Employee
Update name, department, or supervisor. Changes are logged to the audit trail.
#### Merge Duplicate
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
---
### Audit Log
Accessible from the dashboard toolbar (🔍 button). Append-only log of every write action in the system.
- Filter by entity type: **employee** or **violation**
- Filter by action: created, edited, merged, negated, restored, amended, deleted, notes updated
- Paginated with load-more; most recent entries first
The audit log is the authoritative record for compliance review. Nothing in it can be edited or deleted through the UI.
---
### Violation Amendment
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
**Immutable fields:** violation type, incident date, point value.
Each amendment stores a before/after diff for every changed field. Amendment history is accessible from the violation card in the employee's history.
---
### Toast Notifications
All user actions across the application produce **toast notifications** — small slide-in messages at the top right of the screen.
- **Success** (green) — violation submitted, PDF downloaded, employee updated, etc.
- **Error** (red) — API failures, validation errors, PDF generation issues
- **Warning** (gold) — missing required fields, policy alerts
- **Info** (blue) — general informational messages
Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has a progress bar countdown and a manual dismiss button. Up to 5 toasts can stack simultaneously.
---
## Immutability Rules — Quick Reference
| Action | Allowed? | Notes |
|--------|----------|-------|
| Edit violation type | No | Immutable after submission |
| Edit incident date | No | Immutable after submission |
| Edit point value | No | Immutable after submission |
| Edit location / details / witness | Yes | Via Amend |
| Edit acknowledged-by / acknowledged-date | Yes | Via Amend |
| Negate (void) a violation | Yes | Soft delete; reversible |
| Hard delete a violation | Yes | Permanent; use sparingly |
| Edit employee name / dept / supervisor | Yes | Logged to audit trail |
| Merge duplicate employees | Yes | Irreversible |
| Add / edit employee notes | Yes | Does not affect score |
---
## Roadmap
### Shipped
- Container scaffold, violation form, employee intelligence
- Recidivist auto-escalation, tier crossing warning
- PDF generation with prior-points snapshot
- Company dashboard, stat cards, at-risk badges
- Employee profile modal — full history, negate/restore, hard delete
- Employee edit and duplicate merge
- Violation amendment with field-level diff log
- Audit log — filterable, paginated, append-only
- Employee notes and flags with quick-add HR tags
- Point expiration timeline with tier-drop projections
- In-app admin guide (this panel)
- Acknowledgment signature field — employee name + date on form and PDF
- Toast notification system — global feedback for all user actions
---
### Near-term
These are well-scoped additions that fit the current architecture without major changes.
- **CSV export** — one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
---
### Planned
Larger features that require more design work or infrastructure.
- **Violation trends chart** — line/bar chart of violations over time, filterable by department or supervisor. Useful for identifying systemic patterns vs. isolated incidents. Recharts is already available in the frontend bundle.
- **Department heat map** — grid showing violation density and average CPAS score per department. Helps identify team-level risk early.
- **Draft / pending violations** — save a violation as a draft before it's officially logged. Useful when incidents need supervisor review or HR sign-off before they count toward the score.
- **At-risk threshold configuration** — make the 2-point at-risk warning threshold configurable per deployment rather than hardcoded.
---
### Future Considerations
These require meaningful infrastructure additions and should be evaluated against actual operational need before committing.
- **Multi-user auth** — role-based login (admin, supervisor, read-only). Currently the app assumes a trusted internal network with no authentication layer.
- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+, automatically routed to their supervisor.
- **Scheduled digest** — weekly email summary to supervisors showing their employees' current standings and any approaching thresholds.
- **Automated DB backup** — scheduled snapshot of the database to a mounted backup volume or remote destination.
- **Bulk CSV import** — migrate historical violation records from paper logs or a prior system.
- **Dark/light theme toggle** — UI is currently dark-only.
`;
// ——— Component ——————————————————————————————————————————————————————————————
export default function ReadmeModal({ onClose }) {
const bodyRef = useRef(null);
const html = mdToHtml(GUIDE_MD);
const toc = buildToc(GUIDE_MD);
useEffect(() => {
const h = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
const scrollTo = id => {
const el = bodyRef.current?.querySelector(`#${id}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div style={S.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<style>{CSS}</style>
<div style={S.panel} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={S.header}>
<div>
<div style={{ fontSize:'17px', fontWeight:800, letterSpacing:'.3px' }}>
📋 CPAS Tracker Admin Guide
</div>
<div style={{ fontSize:'11px', color:'#9ca0b8', marginTop:'3px' }}>
Feature map · workflows · roadmap · Esc or click outside to close
</div>
</div>
<button style={S.closeBtn} onClick={onClose} aria-label="Close"></button>
</div>
{/* TOC strip */}
<div style={S.toc}>
{toc.map(h => (
<button key={h.id} onClick={() => scrollTo(h.id)} style={{
background:'none', border:'none', cursor:'pointer', padding:'3px 0',
color: h.level === 1 ? '#f8f9fa' : '#d4af37',
fontWeight: h.level === 1 ? 700 : 500,
fontSize:'11px',
}}>
{h.level === 2 ? '↳ ' : ''}{h.text}
</button>
))}
</div>
{/* Body */}
<div
ref={bodyRef}
style={S.body}
className="adm"
dangerouslySetInnerHTML={{ __html: html }}
/>
{/* Footer */}
<div style={S.footer}>
CPAS Violation Tracker · internal admin use only
</div>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import React from 'react';
import { getTier, getNextTier } from './CpasBadge';
/**
* Shows a warning banner if adding `addingPoints` to `currentPoints`
* would cross into a new CPAS tier.
*/
export default function TierWarning({ currentPoints, addingPoints }) {
if (!currentPoints && currentPoints !== 0) return null;
const current = getTier(currentPoints);
const projected = getTier(currentPoints + addingPoints);
if (current.label === projected.label) return null;
const tierUp = getNextTier(currentPoints);
return (
<div style={{
background: '#3b2e00',
border: '2px solid #d4af37',
borderRadius: '6px',
padding: '12px 16px',
margin: '12px 0',
fontSize: '13px',
color: '#ffdf8a',
}}>
<strong style={{ color: '#ffd666' }}> Tier Escalation Warning</strong><br />
Adding <strong>{addingPoints} point{addingPoints !== 1 ? 's' : ''}</strong> will move this employee
from <strong>{current.label}</strong> to <strong>{projected.label}</strong>.
{tierUp && (
<span> Tier threshold crossed at <strong>{tierUp.min} points</strong>.</span>
)}
</div>
);
}
@@ -0,0 +1,145 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
const ToastContext = createContext(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
const VARIANTS = {
success: { bg: '#053321', border: '#0f5132', color: '#9ef7c1', icon: '✓' },
error: { bg: '#3c1114', border: '#f5c6cb', color: '#ffb3b8', icon: '✗' },
info: { bg: '#0c1f3f', border: '#2563eb', color: '#93c5fd', icon: '' },
warning: { bg: '#3b2e00', border: '#d4af37', color: '#ffdf8a', icon: '⚠' },
};
let nextId = 0;
function Toast({ toast, onDismiss }) {
const v = VARIANTS[toast.variant] || VARIANTS.info;
const [exiting, setExiting] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
setExiting(true);
setTimeout(() => onDismiss(toast.id), 280);
}, toast.duration || 4000);
return () => clearTimeout(timerRef.current);
}, [toast.id, toast.duration, onDismiss]);
const handleDismiss = () => {
clearTimeout(timerRef.current);
setExiting(true);
setTimeout(() => onDismiss(toast.id), 280);
};
return (
<div style={{
background: v.bg,
border: `1px solid ${v.border}`,
borderRadius: '8px',
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
color: v.color,
fontSize: '13px',
fontWeight: 500,
minWidth: '320px',
maxWidth: '480px',
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
animation: exiting ? 'toastOut 0.28s ease-in forwards' : 'toastIn 0.28s ease-out',
position: 'relative',
overflow: 'hidden',
}}>
<span style={{ fontSize: '16px', lineHeight: 1, flexShrink: 0, marginTop: '1px' }}>{v.icon}</span>
<span style={{ flex: 1, lineHeight: 1.5 }}>{toast.message}</span>
<button
onClick={handleDismiss}
style={{
background: 'none', border: 'none', color: v.color, cursor: 'pointer',
fontSize: '16px', padding: '0 0 0 8px', opacity: 0.7, lineHeight: 1, flexShrink: 0,
}}
aria-label="Dismiss"
>
×
</button>
<div style={{
position: 'absolute', bottom: 0, left: 0, height: '3px',
background: v.color, opacity: 0.4, borderRadius: '0 0 8px 8px',
animation: `toastProgress ${toast.duration || 4000}ms linear forwards`,
}} />
</div>
);
}
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const dismiss = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const addToast = useCallback((message, variant = 'info', duration = 4000) => {
const id = ++nextId;
setToasts(prev => {
const next = [...prev, { id, message, variant, duration }];
return next.length > 5 ? next.slice(-5) : next;
});
return id;
}, []);
const toast = useCallback({
success: (msg, dur) => addToast(msg, 'success', dur),
error: (msg, dur) => addToast(msg, 'error', dur || 6000),
info: (msg, dur) => addToast(msg, 'info', dur),
warning: (msg, dur) => addToast(msg, 'warning', dur || 5000),
}, [addToast]);
// Inject keyframes once
useEffect(() => {
if (document.getElementById('toast-keyframes')) return;
const style = document.createElement('style');
style.id = 'toast-keyframes';
style.textContent = `
@keyframes toastIn {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
@keyframes toastProgress {
from { width: 100%; }
to { width: 0%; }
}
`;
document.head.appendChild(style);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<div style={{
position: 'fixed',
top: '16px',
right: '16px',
zIndex: 99999,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'none',
}}>
{toasts.map(t => (
<div key={t.id} style={{ pointerEvents: 'auto' }}>
<Toast toast={t} onDismiss={dismiss} />
</div>
))}
</div>
</ToastContext.Provider>
);
}
@@ -0,0 +1,472 @@
import React, { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
import { violationData, violationGroups } from '../data/violations';
import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence';
import CpasBadge from './CpasBadge';
import TierWarning from './TierWarning';
import ViolationHistory from './ViolationHistory';
import ViolationTypeModal from './ViolationTypeModal';
import { useToast } from './ToastProvider';
import { DEPARTMENTS } from '../data/departments';
const s = {
content: { padding: '32px 40px', background: '#111217', borderRadius: '10px', color: '#f8f9fa' },
section: { background: '#181924', borderLeft: '4px solid #d4af37', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
sectionTitle: { color: '#f8f9fa', fontSize: '20px', marginBottom: '15px', fontWeight: 700 },
grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '15px', marginTop: '15px' },
item: { display: 'flex', flexDirection: 'column' },
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px' },
input: { padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa' },
fullCol: { gridColumn: '1 / -1' },
contextBox: { background: '#141623', border: '1px solid #333544', borderRadius: '4px', padding: '10px', fontSize: '12px', color: '#d1d3e0', marginTop: '4px' },
repeatBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '11px', fontWeight: 700, background: '#3b2e00', color: '#ffd666', border: '1px solid #d4af37' },
repeatWarn: { background: '#3b2e00', border: '1px solid #d4af37', borderRadius: '4px', padding: '8px 12px', marginTop: '6px', fontSize: '12px', color: '#ffdf8a' },
pointBox: { background: '#181200', border: '2px solid #d4af37', padding: '15px', borderRadius: '6px', marginTop: '15px', textAlign: 'center' },
pointValue: { fontSize: '24px', fontWeight: 'bold', color: '#ffd666', margin: '10px 0' },
scoreRow: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px', flexWrap: 'wrap' },
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, #d4af37 0%, #ffdf8a 100%)', color: '#000', textTransform: 'uppercase' },
btnPdf: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)', color: 'white', textTransform: 'uppercase' },
btnSecondary: { padding: '15px 40px', fontSize: '16px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa', textTransform: 'uppercase' },
note: { background: '#141623', borderLeft: '4px solid #2196F3', padding: '15px', margin: '20px 0', borderRadius: '4px', fontSize: '13px', color: '#d1d3e0' },
ackSection: { background: '#181924', borderLeft: '4px solid #2196F3', padding: '20px', marginBottom: '30px', borderRadius: '4px', border: '1px solid #2a2b3a' },
ackHint: { fontSize: '12px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
};
const EMPTY_FORM = {
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
violationType: '', incidentDate: '', incidentTime: '',
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
acknowledgedBy: '', acknowledgedDate: '',
};
export default function ViolationForm() {
const [employees, setEmployees] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [violation, setViolation] = useState(null);
const [status, setStatus] = useState(null);
const [lastViolId, setLastViolId] = useState(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [customTypes, setCustomTypes] = useState([]);
const [typeModal, setTypeModal] = useState(null); // null | 'create' | <editing object>
const toast = useToast();
const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
fetchCustomTypes();
}, []);
const fetchCustomTypes = () => {
axios.get('/api/violation-types').then(r => setCustomTypes(r.data)).catch(() => {});
};
// Build a map of custom types keyed by type_key for fast lookup
const customTypeMap = useMemo(() =>
Object.fromEntries(customTypes.map(t => [t.type_key, t])),
[customTypes]
);
// Merge hardcoded and custom violation groups for the dropdown
const mergedGroups = useMemo(() => {
const groups = {};
// Start with all hardcoded groups
Object.entries(violationGroups).forEach(([cat, items]) => {
groups[cat] = [...items];
});
// Add custom types into their respective category, or create new group
customTypes.forEach(t => {
const item = {
key: t.type_key,
name: t.name,
category: t.category,
minPoints: t.min_points,
maxPoints: t.max_points,
chapter: t.chapter || '',
description: t.description || '',
fields: t.fields,
isCustom: true,
customId: t.id,
};
if (!groups[t.category]) groups[t.category] = [];
groups[t.category].push(item);
});
return groups;
}, [customTypes]);
// Resolve a violation definition from either the hardcoded registry or custom types
const resolveViolation = key => {
if (violationData[key]) return violationData[key];
const ct = customTypeMap[key];
if (ct) return {
name: ct.name,
category: ct.category,
chapter: ct.chapter || '',
description: ct.description || '',
minPoints: ct.min_points,
maxPoints: ct.max_points,
fields: ct.fields,
isCustom: true,
customId: ct.id,
};
return null;
};
useEffect(() => {
if (!violation || !form.violationType) return;
const allTime = intel.countsAllTime[form.violationType];
if (allTime && allTime.count >= 1 && violation.minPoints !== violation.maxPoints) {
setForm(prev => ({ ...prev, points: violation.maxPoints }));
} else {
setForm(prev => ({ ...prev, points: violation.minPoints }));
}
}, [form.violationType, violation, intel.countsAllTime]);
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 = resolveViolation(key);
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) { toast.warning('Please select a violation type.'); return; }
if (!form.employeeName) { toast.warning('Please enter an employee name.'); return; }
try {
const empRes = await axios.post('/api/employees', { name: form.employeeName, department: form.department, supervisor: form.supervisor });
const employeeId = empRes.data.id;
const violRes = 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,
acknowledged_by: form.acknowledgedBy || null,
acknowledged_date: form.acknowledgedDate || null,
});
const newId = violRes.data.id;
setLastViolId(newId);
const empList = await axios.get('/api/employees');
setEmployees(empList.data);
toast.success(`Violation #${newId} recorded — click Download PDF to save the document.`);
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
setForm(EMPTY_FORM);
setViolation(null);
} catch (err) {
const msg = err.response?.data?.error || err.message;
toast.error(`Failed to submit: ${msg}`);
setStatus({ ok: false, msg: '✗ Error: ' + msg });
}
};
const handleDownloadPdf = async () => {
if (!lastViolId) return;
setPdfLoading(true);
try {
const response = await axios.get(`/api/violations/${lastViolId}/pdf`, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.download = `CPAS_Violation_${lastViolId}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success('PDF downloaded successfully.');
} catch (err) {
toast.error('PDF generation failed: ' + err.message);
} finally {
setPdfLoading(false);
}
};
const showField = f => violation?.fields?.includes(f);
const priorCount90 = key => intel.counts90[key] || 0;
const isRepeat = key => (intel.countsAllTime[key]?.count || 0) >= 1;
return (
<div style={s.content}>
<div style={s.section}>
<h2 style={s.sectionTitle}>Employee Information</h2>
{intel.score && form.employeeId && (
<div style={s.scoreRow}>
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
<CpasBadge points={intel.score.active_points} />
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
</span>
</div>
)}
{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','John Doe'],['supervisor','Supervisor Name','Jane Smith'],['witnessName','Witness Name (Officer)','Officer Name']].map(([name,label,ph]) => (
<div key={name} style={s.item}>
<label style={s.label}>{label}:</label>
<input style={s.input} type="text" name={name} value={form[name]} onChange={handleChange} placeholder={ph} />
</div>
))}
<div style={s.item}>
<label style={s.label}>Department:</label>
<select style={s.input} name="department" value={form.department} onChange={handleChange}>
<option value="">-- Select Department --</option>
{DEPARTMENTS.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</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 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
<label style={{ ...s.label, marginBottom: 0 }}>Violation Type:</label>
<div style={{ display: 'flex', gap: '6px' }}>
{violation?.isCustom && (
<button
type="button"
onClick={() => setTypeModal(customTypeMap[form.violationType])}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #4caf50', background: '#1a2e1a', color: '#4caf50', cursor: 'pointer', fontWeight: 600 }}
>
Edit Type
</button>
)}
<button
type="button"
onClick={() => setTypeModal('create')}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #d4af37', background: '#181200', color: '#ffd666', cursor: 'pointer', fontWeight: 600 }}
title="Add a new custom violation type"
>
+ Add Type
</button>
</div>
</div>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
<option value="">-- Select Violation Type --</option>
{Object.entries(mergedGroups).map(([group, items]) => (
<optgroup key={group} label={group}>
{items.map(v => {
const prior = priorCount90(v.key);
return (
<option key={v.key} value={v.key}>
{v.name}{v.isCustom ? ' ✦' : ''}{prior > 0 ? `${prior}x in 90 days` : ''}
</option>
);
})}
</optgroup>
))}
</select>
{violation && (
<div style={s.contextBox}>
<strong>{violation.name}</strong>
{violation.isCustom && (
<span style={{ display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50' }}>
Custom
</span>
)}
{isRepeat(form.violationType) && form.employeeId && (
<span style={s.repeatBadge}>
Repeat {intel.countsAllTime[form.violationType]?.count}x prior
</span>
)}
<br />{violation.description}<br />
<span style={{ fontSize: '11px', color: '#a0a3ba' }}>{violation.chapter}</span>
</div>
)}
{violation && isRepeat(form.violationType) && form.employeeId && violation.minPoints !== violation.maxPoints && (
<div style={s.repeatWarn}>
<strong>Repeat offense detected.</strong> Point slider set to maximum ({violation.maxPoints} pts) per recidivist policy. Adjust if needed.
</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>
{intel.score && violation && (
<TierWarning
currentPoints={intel.score.active_points}
addingPoints={parseInt(form.points) || 0}
/>
)}
{violation && (
<div style={s.pointBox}>
<h4 style={{ color: '#ffdf8a', 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={{ width: '100%', marginTop: '10px' }} 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: '#d1d3e0' }}>Adjust to reflect severity and context</p>
</div>
)}
</div>
{/* Acknowledgment Signature Section */}
<div style={s.ackSection}>
<h2 style={{ ...s.sectionTitle, fontSize: '17px' }}>Employee Acknowledgment</h2>
<p style={{ fontSize: '12px', color: '#9ca0b8', marginBottom: '14px', lineHeight: 1.6 }}>
If the employee is present and acknowledges receipt of this violation, enter their name and the date below.
This replaces the blank signature line on the PDF with a recorded acknowledgment.
</p>
<div style={s.grid}>
<div style={s.item}>
<label style={s.label}>Acknowledged By (Employee Name):</label>
<input style={s.input} type="text" name="acknowledgedBy" value={form.acknowledgedBy} onChange={handleChange} placeholder="Employee's printed name" />
<div style={s.ackHint}>Leave blank if employee is not present or declines to sign</div>
</div>
<div style={s.item}>
<label style={s.label}>Acknowledgment Date:</label>
<input style={s.input} type="date" name="acknowledgedDate" value={form.acknowledgedDate} onChange={handleChange} />
<div style={s.ackHint}>Date the employee received and acknowledged this document</div>
</div>
</div>
</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); setLastViolId(null); }}>
Clear Form
</button>
</div>
{lastViolId && status?.ok && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<button
type="button"
style={{ ...s.btnPdf, opacity: pdfLoading ? 0.7 : 1 }}
onClick={handleDownloadPdf}
disabled={pdfLoading}
>
{pdfLoading ? '⏳ Generating PDF...' : '⬇ Download PDF'}
</button>
<p style={{ fontSize: '11px', color: '#9ca0b8', marginTop: '6px' }}>
Violation #{lastViolId} click to download the signed violation document
</p>
</div>
)}
{status && <div style={status.ok ? { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#053321', color: '#9ef7c1', border: '1px solid #0f5132' } : { marginTop: '15px', padding: '15px', borderRadius: '6px', textAlign: 'center', fontWeight: 600, background: '#3c1114', color: '#ffb3b8', border: '1px solid #f5c6cb' }}>{status.msg}</div>}
</form>
{form.employeeId && (
<div style={s.section}>
<h2 style={s.sectionTitle}>Violation History</h2>
<ViolationHistory history={intel.history} loading={intel.loading} />
</div>
)}
{typeModal && (
<ViolationTypeModal
editing={typeModal === 'create' ? null : typeModal}
onClose={() => setTypeModal(null)}
onSaved={saved => {
fetchCustomTypes();
setTypeModal(null);
// Auto-select the newly created type; do nothing on delete (saved === null)
if (saved) {
const v = {
name: saved.name,
category: saved.category,
chapter: saved.chapter || '',
description: saved.description || '',
minPoints: saved.min_points,
maxPoints: saved.max_points,
fields: saved.fields,
isCustom: true,
customId: saved.id,
};
setViolation(v);
setForm(prev => ({ ...prev, violationType: saved.type_key, points: saved.min_points }));
} else {
// Type was deleted — clear selection if it was the active type
setForm(prev => {
const stillExists = violationData[prev.violationType] || false;
return stillExists ? prev : { ...prev, violationType: '', points: 1 };
});
setViolation(null);
}
}}
/>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
const s = {
wrapper: { marginTop: '24px' },
title: { color: '#b5b5c0', fontSize: '16px', fontWeight: 700, marginBottom: '10px' },
table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px', background: '#111217', borderRadius: '6px', overflow: 'hidden', border: '1px solid #222' },
th: { background: '#000000', color: '#f8f9fa', padding: '8px 10px', textAlign: 'left', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' },
td: { padding: '8px 10px', borderBottom: '1px solid #1c1d29', color: '#f8f9fa', verticalAlign: 'middle' },
trEven: { background: '#111217' },
trOdd: { background: '#151622' },
pts: { fontWeight: 700, color: '#667eea' },
toggle: { background: 'none', border: 'none', color: '#667eea', cursor: 'pointer', fontSize: '13px', padding: 0, textDecoration: 'underline' },
empty: { color: '#77798a', fontStyle: 'italic', fontSize: '13px', marginTop: '8px' },
};
function formatDate(d) {
if (!d) return '';
const dt = new Date(d + 'T12:00:00');
return dt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Chicago' });
}
export default function ViolationHistory({ history, loading }) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? history : history.slice(0, 5);
if (loading) return <p style={s.empty}>Loading history...</p>;
if (!history.length) return <p style={s.empty}>No violations on record for this employee.</p>;
return (
<div style={s.wrapper}>
<div style={s.title}>Recent Violations ({history.length} total)</div>
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Date</th>
<th style={s.th}>Violation</th>
<th style={s.th}>Category</th>
<th style={s.th}>Points</th>
<th style={s.th}>Details</th>
</tr>
</thead>
<tbody>
{visible.map((v, i) => (
<tr key={v.id} style={i % 2 === 0 ? s.trEven : s.trOdd}>
<td style={s.td}>{formatDate(v.incident_date)}</td>
<td style={s.td}>{v.violation_name}</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{v.category}</td>
<td style={{ ...s.td, ...s.pts }}>{v.points}</td>
<td style={{ ...s.td, color: '#c0c2d6' }}>{v.details || ''}</td>
</tr>
))}
</tbody>
</table>
{history.length > 5 && (
<div style={{ marginTop: '8px' }}>
<button style={s.toggle} onClick={() => setExpanded(e => !e)}>
{expanded ? '▲ Show less' : `▼ Show all ${history.length} violations`}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,292 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { useToast } from './ToastProvider';
// Existing hardcoded categories — used for datalist autocomplete
const KNOWN_CATEGORIES = [
'Attendance & Punctuality',
'Administrative Integrity',
'Financial Stewardship',
'Operational Response',
'Professional Conduct',
'Work From Home',
'Safety & Security',
];
const CONTEXT_FIELDS = [
{ key: 'time', label: 'Incident Time' },
{ key: 'minutes', label: 'Minutes Late' },
{ key: 'amount', label: 'Amount / Value' },
{ key: 'location', label: 'Location / Context' },
{ key: 'description', label: 'Additional Details' },
];
const s = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' },
modal: { background: '#111217', border: '1px solid #2a2b3a', borderRadius: '10px', width: '100%', maxWidth: '620px', maxHeight: '90vh', overflowY: 'auto', padding: '32px' },
title: { color: '#f8f9fa', fontSize: '20px', fontWeight: 700, marginBottom: '24px', borderBottom: '1px solid #2a2b3a', paddingBottom: '12px' },
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px', display: 'block' },
input: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', boxSizing: 'border-box' },
textarea: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '13px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', resize: 'vertical', minHeight: '80px', boxSizing: 'border-box' },
group: { marginBottom: '18px' },
hint: { fontSize: '11px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
row: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' },
toggle: { display: 'flex', gap: '8px', marginTop: '6px' },
toggleBtn: (active) => ({
padding: '7px 18px', borderRadius: '4px', fontSize: '13px', fontWeight: 600, cursor: 'pointer', border: '1px solid',
background: active ? '#d4af37' : '#050608',
color: active ? '#000' : '#9ca0b8',
borderColor: active ? '#d4af37' : '#333544',
}),
fieldGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' },
checkbox: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#d1d3e0', cursor: 'pointer' },
btnRow: { display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '28px', paddingTop: '16px', borderTop: '1px solid #2a2b3a' },
btnSave: { padding: '10px 28px', fontSize: '14px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)', color: '#000' },
btnDanger: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #721c24', borderRadius: '6px', cursor: 'pointer', background: '#3c1114', color: '#ffb3b8' },
btnCancel: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa' },
section: { background: '#181924', border: '1px solid #2a2b3a', borderRadius: '6px', padding: '16px', marginBottom: '18px' },
secTitle: { color: '#d4af37', fontSize: '13px', fontWeight: 700, marginBottom: '12px', textTransform: 'uppercase', letterSpacing: '0.05em' },
customBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50', verticalAlign: 'middle' },
};
const EMPTY = {
name: '', category: '', chapter: '', description: '',
pointType: 'fixed', // 'fixed' | 'sliding'
fixedPoints: 1,
minPoints: 1,
maxPoints: 5,
fields: ['description'],
};
export default function ViolationTypeModal({ onClose, onSaved, editing = null }) {
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const toast = useToast();
// Populate form when editing an existing type
useEffect(() => {
if (editing) {
const isSliding = editing.min_points !== editing.max_points;
setForm({
name: editing.name,
category: editing.category,
chapter: editing.chapter || '',
description: editing.description || '',
pointType: isSliding ? 'sliding' : 'fixed',
fixedPoints: isSliding ? editing.min_points : editing.min_points,
minPoints: editing.min_points,
maxPoints: editing.max_points,
fields: editing.fields || ['description'],
});
}
}, [editing]);
const set = (key, val) => setForm(prev => ({ ...prev, [key]: val }));
const toggleField = key => {
setForm(prev => ({
...prev,
fields: prev.fields.includes(key)
? prev.fields.filter(f => f !== key)
: [...prev.fields, key],
}));
};
const handleSave = async () => {
if (!form.name.trim()) { toast.warning('Violation name is required.'); return; }
if (!form.category.trim()) { toast.warning('Category is required.'); return; }
const minPts = form.pointType === 'fixed' ? parseInt(form.fixedPoints) || 1 : parseInt(form.minPoints) || 1;
const maxPts = form.pointType === 'fixed' ? minPts : parseInt(form.maxPoints) || 1;
if (maxPts < minPts) { toast.warning('Max points must be >= min points.'); return; }
if (form.fields.length === 0) { toast.warning('Select at least one context field.'); return; }
const payload = {
name: form.name.trim(),
category: form.category.trim(),
chapter: form.chapter.trim() || null,
description: form.description.trim() || null,
min_points: minPts,
max_points: maxPts,
fields: form.fields,
};
setSaving(true);
try {
let saved;
if (editing) {
const res = await axios.put(`/api/violation-types/${editing.id}`, payload);
saved = res.data;
toast.success(`"${saved.name}" updated.`);
} else {
const res = await axios.post('/api/violation-types', payload);
saved = res.data;
toast.success(`"${saved.name}" added to violation types.`);
}
onSaved(saved);
} catch (err) {
toast.error(err.response?.data?.error || err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editing) return;
if (!window.confirm(`Delete "${editing.name}"? This cannot be undone and will fail if any violations reference this type.`)) return;
setDeleting(true);
try {
await axios.delete(`/api/violation-types/${editing.id}`);
toast.success(`"${editing.name}" deleted.`);
onSaved(null); // null signals a deletion to the parent
} catch (err) {
toast.error(err.response?.data?.error || err.message);
} finally {
setDeleting(false);
}
};
return (
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={s.modal}>
<div style={s.title}>
{editing ? 'Edit Violation Type' : 'Add Violation Type'}
{editing && <span style={s.customBadge}>CUSTOM</span>}
</div>
{/* Basic Info */}
<div style={s.section}>
<div style={s.secTitle}>Violation Definition</div>
<div style={s.group}>
<label style={s.label}>Violation Name *</label>
<input
style={s.input}
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. Unauthorized System Access"
/>
</div>
<div style={s.group}>
<label style={s.label}>Category *</label>
<input
style={s.input}
type="text"
list="vt-categories"
value={form.category}
onChange={e => set('category', e.target.value)}
placeholder="Select existing or type new category"
/>
<datalist id="vt-categories">
{KNOWN_CATEGORIES.map(c => <option key={c} value={c} />)}
</datalist>
<div style={s.hint}>Choose an existing category or type a new one to create a new group in the dropdown.</div>
</div>
<div style={s.group}>
<label style={s.label}>Handbook Reference / Chapter</label>
<input
style={s.input}
type="text"
value={form.chapter}
onChange={e => set('chapter', e.target.value)}
placeholder="e.g. Chapter 4, Section 6"
/>
</div>
<div style={s.group}>
<label style={s.label}>Description / Reference Text</label>
<textarea
style={s.textarea}
value={form.description}
onChange={e => set('description', e.target.value)}
placeholder="Paste the relevant handbook language or describe the infraction in plain terms..."
/>
<div style={s.hint}>Shown in the context box on the violation form and printed on the PDF.</div>
</div>
</div>
{/* Point Assignment */}
<div style={s.section}>
<div style={s.secTitle}>Point Assignment</div>
<label style={s.label}>Point Type</label>
<div style={s.toggle}>
<button type="button" style={s.toggleBtn(form.pointType === 'fixed')} onClick={() => set('pointType', 'fixed')}>Fixed</button>
<button type="button" style={s.toggleBtn(form.pointType === 'sliding')} onClick={() => set('pointType', 'sliding')}>Sliding Range</button>
</div>
<div style={{ ...s.hint, marginTop: '6px' }}>
Fixed = exact value every time. Sliding = supervisor adjusts within a min/max range.
</div>
{form.pointType === 'fixed' ? (
<div style={{ ...s.group, marginTop: '14px' }}>
<label style={s.label}>Points (Fixed)</label>
<input
style={{ ...s.input, width: '120px' }}
type="number" min="1" max="30"
value={form.fixedPoints}
onChange={e => set('fixedPoints', e.target.value)}
/>
</div>
) : (
<div style={{ ...s.row, marginTop: '14px' }}>
<div style={s.group}>
<label style={s.label}>Min Points</label>
<input
style={s.input}
type="number" min="1" max="30"
value={form.minPoints}
onChange={e => set('minPoints', e.target.value)}
/>
</div>
<div style={s.group}>
<label style={s.label}>Max Points</label>
<input
style={s.input}
type="number" min="1" max="30"
value={form.maxPoints}
onChange={e => set('maxPoints', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Context Fields */}
<div style={s.section}>
<div style={s.secTitle}>Context Fields</div>
<div style={s.hint}>Select which additional fields appear on the violation form for this type.</div>
<div style={s.fieldGrid}>
{CONTEXT_FIELDS.map(({ key, label }) => (
<label key={key} style={s.checkbox}>
<input
type="checkbox"
checked={form.fields.includes(key)}
onChange={() => toggleField(key)}
/>
{label}
</label>
))}
</div>
</div>
<div style={s.btnRow}>
{editing && (
<button type="button" style={s.btnDanger} onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete Type'}
</button>
)}
<button type="button" style={s.btnCancel} onClick={onClose}>Cancel</button>
<button type="button" style={s.btnSave} onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : editing ? 'Save Changes' : 'Add Violation Type'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
export const DEPARTMENTS = [
'Administrative',
'Business Development',
'Design and Content',
'Executive',
'Implementation and Support',
'Operations',
'Production',
];
@@ -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;
}, {});
@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
/**
* Fetches CPAS score, 90-day violation type counts, and full history
* for a given employeeId. Re-fetches whenever employeeId changes.
*/
export default function useEmployeeIntelligence(employeeId) {
const [score, setScore] = useState(null);
const [counts90, setCounts90] = useState({}); // { violation_type: count } 90-day
const [countsAllTime, setCountsAllTime] = useState({}); // { violation_type: { count, max_points_used } }
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!employeeId) {
setScore(null);
setCounts90({});
setCountsAllTime({});
setHistory([]);
return;
}
setLoading(true);
Promise.all([
axios.get(`/api/employees/${employeeId}/score`),
axios.get(`/api/employees/${employeeId}/violation-counts`),
axios.get(`/api/employees/${employeeId}/violation-counts/alltime`),
axios.get(`/api/violations/employee/${employeeId}?limit=20`),
]).then(([scoreRes, counts90Res, allTimeRes, historyRes]) => {
setScore(scoreRes.data);
setCounts90(counts90Res.data);
setCountsAllTime(allTimeRes.data);
setHistory(historyRes.data);
}).catch(console.error)
.finally(() => setLoading(false));
}, [employeeId]);
return { score, counts90, countsAllTime, history, loading };
}
@@ -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>
);
@@ -0,0 +1,113 @@
/* Mobile-Responsive Utilities for CPAS Tracker */
/* Target: Standard phones 375px+ with graceful degradation */
/* Base responsive utilities */
@media (max-width: 768px) {
/* Hide scrollbars but keep functionality */
* {
-webkit-overflow-scrolling: touch;
}
/* Touch-friendly tap targets (min 44px) */
button, a, input, select {
min-height: 44px;
}
/* Improve form input sizing on mobile */
input, select, textarea {
font-size: 16px !important; /* Prevents iOS zoom on focus */
}
}
/* Tablet and below */
@media (max-width: 1024px) {
.hide-tablet {
display: none !important;
}
}
/* Mobile portrait and landscape */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
.mobile-full-width {
width: 100% !important;
}
.mobile-text-center {
text-align: center !important;
}
.mobile-no-padding {
padding: 0 !important;
}
.mobile-small-padding {
padding: 12px !important;
}
/* Stack flex containers vertically */
.mobile-stack {
flex-direction: column !important;
}
/* Allow horizontal scroll for tables */
.mobile-scroll-x {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
/* Card-based layout helpers */
.mobile-card {
display: block !important;
padding: 16px;
margin-bottom: 12px;
border-radius: 8px;
background: #181924;
border: 1px solid #2a2b3a;
}
.mobile-card-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #1c1d29;
}
.mobile-card-row:last-child {
border-bottom: none;
}
.mobile-card-label {
font-weight: 600;
color: #9ca0b8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mobile-card-value {
font-weight: 600;
color: #f8f9fa;
text-align: right;
}
}
/* Small mobile phones */
@media (max-width: 480px) {
.hide-small-mobile {
display: none !important;
}
}
/* Utility for sticky positioning on mobile */
@media (max-width: 768px) {
.mobile-sticky-top {
position: sticky;
top: 0;
z-index: 100;
background: #000000;
}
}
@@ -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'
}
});
@@ -0,0 +1,93 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'cpas.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
// ── Migrations for existing DBs ──────────────────────────────────────────────
const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name);
if (!cols.includes('negated')) db.exec("ALTER TABLE violations ADD COLUMN negated INTEGER NOT NULL DEFAULT 0");
if (!cols.includes('negated_at')) db.exec("ALTER TABLE violations ADD COLUMN negated_at DATETIME");
if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD COLUMN prior_active_points INTEGER");
if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT");
if (!cols.includes('acknowledged_by')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_by TEXT");
if (!cols.includes('acknowledged_date')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_date TEXT");
// Employee notes column (free-text, does not affect scoring)
const empCols = db.prepare('PRAGMA table_info(employees)').all().map(c => c.name);
if (!empCols.includes('notes')) db.exec("ALTER TABLE employees ADD COLUMN notes TEXT");
// Ensure resolutions table exists
db.exec(`CREATE TABLE IF NOT EXISTS violation_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
resolution_type TEXT NOT NULL,
details TEXT,
resolved_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ── Feature: Violation Amendments ────────────────────────────────────────────
// Stores a field-level diff every time a violation's editable fields are changed.
db.exec(`CREATE TABLE IF NOT EXISTS violation_amendments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
changed_by TEXT,
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ── Feature: Audit Log ───────────────────────────────────────────────────────
// Append-only record of every write action across the system.
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
performed_by TEXT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ── Feature: Custom Violation Types ──────────────────────────────────────────
// Persisted violation type definitions created via the UI. type_key is prefixed
// with 'custom_' to prevent collisions with hardcoded violation keys.
db.exec(`CREATE TABLE IF NOT EXISTS violation_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Custom',
chapter TEXT,
description TEXT,
min_points INTEGER NOT NULL DEFAULT 1,
max_points INTEGER NOT NULL DEFAULT 1,
fields TEXT NOT NULL DEFAULT '["description"]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Recreate view so it always filters negated rows
db.exec(`DROP VIEW IF EXISTS active_cpas_scores;
CREATE VIEW active_cpas_scores AS
SELECT
employee_id,
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;`);
console.log('[DB] Connected:', dbPath);
module.exports = db;
@@ -0,0 +1,49 @@
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 DEFAULT 'General',
points INTEGER NOT NULL,
incident_date TEXT NOT NULL,
incident_time TEXT,
location TEXT,
details TEXT,
submitted_by TEXT,
witness_name TEXT,
negated INTEGER NOT NULL DEFAULT 0,
negated_at DATETIME,
prior_active_points INTEGER, -- snapshot at time of logging
prior_tier_label TEXT, -- optional human-readable tier
acknowledged_by TEXT, -- employee name who acknowledged receipt
acknowledged_date TEXT, -- date of acknowledgment (YYYY-MM-DD)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS violation_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
resolution_type TEXT NOT NULL,
details TEXT,
resolved_by TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Active score: only non-negated violations in rolling 90 days
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
SELECT
employee_id,
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;
@@ -0,0 +1,893 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CPAS Tracker — Demo Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--gold: #d4af37;
--gold-lt: #ffdf8a;
--gold-dk: #a88520;
--bg: #050608;
--bg-nav: #000000;
--bg-card: #111217;
--bg-section: #181924;
--border: #222;
--border-lt: #2a2b3a;
--text: #f8f9fa;
--text-muted: #9ca0b8;
--text-dim: #d1d3e0;
--green: #28a745;
--green-bg: #d4edda;
--yellow: #856404;
--yellow-bg: #fff3cd;
--red: #d9534f;
--red-bg: #f8d7da;
--red-dk: #721c24;
--red-dk-bg: #f5c6cb;
--sep: #721c24;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
/* ── DEMO BANNER ── */
.demo-banner {
background: linear-gradient(90deg, #1a1200 0%, #2a1f00 50%, #1a1200 100%);
border-bottom: 1px solid var(--gold-dk);
padding: 8px 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--gold-lt);
letter-spacing: 0.8px;
position: sticky;
top: 0;
z-index: 1000;
}
.demo-banner .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--gold);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
/* ── NAV ── */
nav {
background: var(--bg-nav);
padding: 0 40px;
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid #333;
position: sticky;
top: 33px;
z-index: 999;
}
.logo-wrap {
display: flex; align-items: center;
margin-right: 32px; padding: 14px 0;
gap: 10px;
}
.logo-icon {
width: 28px; height: 28px;
background: linear-gradient(135deg, var(--gold), var(--gold-lt));
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 900; color: #000;
font-family: 'Syne', sans-serif;
flex-shrink: 0;
}
.logo-text {
color: var(--text);
font-weight: 800;
font-size: 18px;
letter-spacing: 0.5px;
font-family: 'Syne', sans-serif;
}
.nav-tab {
padding: 18px 22px;
color: rgba(248,249,250,0.55);
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 400;
font-size: 14px;
background: none;
border-top: none; border-left: none; border-right: none;
transition: color 0.2s;
text-decoration: none;
display: inline-block;
}
.nav-tab.active {
color: var(--text);
border-bottom-color: var(--gold);
font-weight: 700;
}
.nav-tab:hover { color: var(--text); }
.nav-docs {
margin-left: auto;
background: none;
border: 1px solid var(--border-lt);
color: var(--text-muted);
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
font-weight: 600;
}
/* ── MAIN LAYOUT ── */
.main {
max-width: 1100px;
margin: 30px auto;
padding: 0 20px 60px;
}
/* ── HERO ── */
.hero {
background: var(--bg-card);
border-radius: 10px;
border: 1px solid var(--border);
padding: 48px 48px 40px;
margin-bottom: 24px;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, transparent, var(--gold), var(--gold-lt), var(--gold), transparent);
}
.hero::after {
content: 'DEMO';
position: absolute;
top: 20px; right: 24px;
font-family: 'DM Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
color: var(--gold-dk);
border: 1px solid var(--gold-dk);
padding: 2px 8px;
border-radius: 3px;
}
.hero-eyebrow {
font-family: 'DM Mono', monospace;
font-size: 11px;
letter-spacing: 2px;
color: var(--gold);
text-transform: uppercase;
margin-bottom: 14px;
}
.hero h1 {
font-family: 'Syne', sans-serif;
font-size: 36px;
font-weight: 800;
color: var(--text);
line-height: 1.1;
margin-bottom: 14px;
}
.hero h1 span { color: var(--gold); }
.hero p {
font-size: 15px;
color: var(--text-dim);
max-width: 580px;
line-height: 1.7;
margin-bottom: 28px;
}
.hero-stats {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.hero-stat {
display: flex; flex-direction: column; gap: 3px;
}
.hero-stat .val {
font-family: 'Syne', sans-serif;
font-size: 28px;
font-weight: 800;
color: var(--gold-lt);
line-height: 1;
}
.hero-stat .lbl {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.5px;
text-transform: uppercase;
font-family: 'DM Mono', monospace;
}
.hero-stat-divider {
width: 1px; background: var(--border-lt);
align-self: stretch; margin: 4px 0;
}
/* ── SECTION TITLE ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.section-title::before {
content: '';
display: block;
width: 3px; height: 14px;
background: var(--gold);
border-radius: 2px;
}
/* ── KPI CARDS ROW ── */
.kpi-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 22px;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.15s;
}
.kpi-card:hover { border-color: var(--border-lt); transform: translateY(-1px); }
.kpi-card .kpi-label {
font-size: 11px;
font-family: 'DM Mono', monospace;
letter-spacing: 0.8px;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 10px;
}
.kpi-card .kpi-val {
font-family: 'Syne', sans-serif;
font-size: 32px;
font-weight: 800;
color: var(--text);
line-height: 1;
margin-bottom: 6px;
}
.kpi-card .kpi-sub { font-size: 11px; color: var(--text-muted); }
.kpi-card .kpi-accent { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; }
.kpi-accent-gold { background: linear-gradient(90deg, var(--gold-dk), var(--gold)); }
.kpi-accent-red { background: linear-gradient(90deg, #a02020, #e74c3c); }
.kpi-accent-blue { background: linear-gradient(90deg, #1a3a6a, #3b82f6); }
.kpi-accent-green{ background: linear-gradient(90deg, #0a3d20, #28a745); }
/* ── TWO COLUMNS ── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.panel-head {
background: var(--bg-section);
border-bottom: 1px solid var(--border);
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 13px; font-weight: 700;
color: var(--text); font-family: 'Syne', sans-serif;
}
/* ── EMPLOYEE TABLE ── */
.emp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.emp-table th {
padding: 10px 16px; text-align: left;
font-size: 10px; font-family: 'DM Mono', monospace;
letter-spacing: 1px; text-transform: uppercase;
color: var(--text-muted); border-bottom: 1px solid var(--border);
background: var(--bg-section);
}
.emp-table td { padding: 11px 16px; border-bottom: 1px solid #18191f; vertical-align: middle; }
.emp-table tr:last-child td { border-bottom: none; }
.emp-table tr:hover td { background: rgba(255,255,255,0.02); }
.emp-name { font-weight: 600; color: var(--text); }
/* ── TIER BADGES ── */
.tier-badge {
display: inline-block; padding: 3px 9px; border-radius: 10px;
font-size: 11px; font-weight: 700; white-space: nowrap; border: 1px solid;
}
.tier-0 { color: #28a745; background: #d4edda; border-color: #28a745; }
.tier-1 { color: #856404; background: #fff3cd; border-color: #c9a000; }
.tier-2 { color: #d9534f; background: #f8d7da; border-color: #d9534f; }
.tier-3 { color: #d9534f; background: #f8d7da; border-color: #d9534f; }
.tier-4 { color: #721c24; background: #f5c6cb; border-color: #c0392b; }
.tier-5 { color: #721c24; background: #f5c6cb; border-color: #c0392b; }
.tier-6 { color: #fff; background: #721c24; border-color: #5a1520; }
/* ── VIOLATION FEED ── */
.viol-item {
padding: 13px 18px; border-bottom: 1px solid #18191f;
display: flex; align-items: flex-start; gap: 12px;
}
.viol-item:last-child { border-bottom: none; }
.viol-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.viol-dot-red { background: #e74c3c; box-shadow: 0 0 6px rgba(231,76,60,0.5); }
.viol-dot-yellow { background: var(--gold); box-shadow: 0 0 6px rgba(212,175,55,0.5); }
.viol-dot-green { background: #28a745; }
.viol-info { flex: 1; min-width: 0; }
.viol-name { font-size: 13px; font-weight: 600; color: var(--text); }
.viol-type { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.viol-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: var(--text-muted); font-family: 'DM Mono', monospace; }
.viol-pts { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 800; color: var(--gold-lt); flex-shrink: 0; }
.repeat-tag { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; font-weight: 700; background: #3b2e00; color: #ffd666; border: 1px solid var(--gold-dk); margin-left: 4px; }
/* ── DEPT BREAKDOWN ── */
.dept-row { padding: 12px 18px; border-bottom: 1px solid #18191f; display: flex; align-items: center; gap: 12px; }
.dept-row:last-child { border-bottom: none; }
.dept-name { font-size: 13px; color: var(--text-dim); min-width: 160px; }
.dept-bar-track { flex: 1; height: 6px; background: var(--border-lt); border-radius: 3px; overflow: hidden; }
.dept-bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--gold-dk), var(--gold)); }
.dept-count { font-family: 'DM Mono', monospace; font-size: 12px; color: var(--text-muted); min-width: 28px; text-align: right; }
/* ── FORM PREVIEW ── */
.form-preview { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; margin-bottom: 24px; overflow: hidden; }
.form-section { background: var(--bg-section); border-left: 4px solid var(--gold); padding: 20px 24px; margin: 20px; border-radius: 4px; border-top: 1px solid var(--border-lt); border-right: 1px solid var(--border-lt); border-bottom: 1px solid var(--border-lt); }
.form-section-title { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 700; color: var(--text); margin-bottom: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
.form-item { display: flex; flex-direction: column; gap: 5px; }
.form-label { font-size: 12px; font-weight: 600; color: #e5e7f1; }
.form-input { padding: 9px 12px; border: 1px solid var(--border-lt); border-radius: 4px; font-size: 13px; background: #050608; color: var(--text-muted); font-family: 'Inter', sans-serif; pointer-events: none; }
.form-input.filled { color: var(--text); border-color: #3a3d52; }
.point-val { font-family: 'Syne', sans-serif; font-size: 28px; font-weight: 800; color: var(--gold-lt); }
/* ── TIER SCALE ── */
.tier-timeline { display: flex; align-items: stretch; border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
.tier-seg { flex: 1; padding: 12px 8px 10px; text-align: center; border-right: 1px solid rgba(255,255,255,0.05); }
.tier-seg:last-child { border-right: none; }
.tier-seg .ts-pts { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 800; margin-bottom: 3px; }
.tier-seg .ts-label { font-size: 9px; font-family: 'DM Mono', monospace; letter-spacing: 0.5px; opacity: 0.8; line-height: 1.3; }
.ts-0 { background: rgba(40,167,69,0.12); color: #28a745; }
.ts-1 { background: rgba(133,100,4,0.15); color: #c9a000; }
.ts-2 { background: rgba(217,83,79,0.15); color: #d9534f; }
.ts-3 { background: rgba(217,83,79,0.18); color: #d9534f; }
.ts-4 { background: rgba(114,28,36,0.20); color: #e87070; }
.ts-5 { background: rgba(114,28,36,0.25); color: #e87070; }
.ts-6 { background: rgba(114,28,36,0.50); color: #ff9999; }
/* ── AUDIT LOG ── */
.audit-item { padding: 11px 18px; border-bottom: 1px solid #18191f; display: flex; align-items: center; gap: 12px; font-size: 12px; }
.audit-item:last-child { border-bottom: none; }
.audit-time { font-family: 'DM Mono', monospace; color: var(--text-muted); font-size: 11px; min-width: 80px; }
.audit-action { flex: 1; color: var(--text-dim); }
.audit-action strong { color: var(--text); font-weight: 600; }
.audit-pts { font-family: 'DM Mono', monospace; font-size: 11px; color: var(--gold); font-weight: 700; }
/* ── FEATURES ── */
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 24px; }
.feature-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 22px; transition: border-color 0.2s, transform 0.15s; }
.feature-card:hover { border-color: var(--border-lt); transform: translateY(-2px); }
.feature-icon { font-size: 22px; margin-bottom: 12px; display: block; }
.feature-title { font-family: 'Syne', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
.feature-desc { font-size: 12px; color: var(--text-muted); line-height: 1.6; }
/* ── FOOTER ── */
footer {
border-top: 1px solid var(--border);
padding: 20px 40px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: 11px;
color: var(--text-muted);
font-family: 'DM Mono', monospace;
background: var(--bg-nav);
}
.footer-left {
display: flex;
align-items: center;
gap: 18px;
}
.footer-brand {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 700;
color: var(--text-dim);
}
.footer-copy {
color: var(--text-muted);
font-size: 11px;
}
.footer-gitea {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
text-decoration: none;
padding: 4px 10px;
border: 1px solid var(--border-lt);
border-radius: 5px;
transition: border-color 0.2s, color 0.2s;
font-size: 11px;
}
.footer-gitea:hover {
border-color: var(--gold-dk);
color: var(--gold-lt);
}
.footer-gitea svg {
width: 14px;
height: 14px;
fill: currentColor;
flex-shrink: 0;
}
.footer-right {
display: flex;
align-items: center;
gap: 18px;
}
.footer-ticker {
display: flex;
align-items: center;
gap: 8px;
background: rgba(212,175,55,0.06);
border: 1px solid rgba(212,175,55,0.2);
border-radius: 5px;
padding: 4px 12px;
}
.footer-ticker-label {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.footer-ticker-time {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--gold);
font-weight: 500;
letter-spacing: 1px;
}
.footer-ticker-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--gold);
animation: pulse 2s infinite;
flex-shrink: 0;
}
.footer-divider {
width: 1px;
height: 16px;
background: var(--border-lt);
}
/* ── ANIMATIONS ── */
.fade-in { opacity: 0; transform: translateY(16px); animation: fadeUp 0.5s ease forwards; }
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
.fade-in:nth-child(1) { animation-delay: 0.05s; }
.fade-in:nth-child(2) { animation-delay: 0.10s; }
.fade-in:nth-child(3) { animation-delay: 0.15s; }
.fade-in:nth-child(4) { animation-delay: 0.20s; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
@media (max-width: 768px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.features-grid { grid-template-columns: 1fr 1fr; }
.hero { padding: 30px 24px; }
.hero h1 { font-size: 26px; }
nav { padding: 0 16px; }
.main { padding: 0 12px 60px; }
footer { padding: 16px 20px; flex-direction: column; align-items: flex-start; }
.footer-right { flex-wrap: wrap; }
}
</style>
</head>
<body>
<!-- Demo Banner -->
<div class="demo-banner">
<div class="dot"></div>
DEMO ENVIRONMENT — Simulated data for stakeholder preview only — Not connected to live database
<div class="dot"></div>
</div>
<!-- Navigation -->
<nav>
<div class="logo-wrap">
<div class="logo-icon">C</div>
<div class="logo-text">CPAS Tracker</div>
</div>
<a class="nav-tab active" href="#" onclick="switchTab('dashboard', this); return false;">📊 Dashboard</a>
<a class="nav-tab" href="#" onclick="switchTab('violations', this); return false;">+ New Violation</a>
<button class="nav-docs">? Docs</button>
</nav>
<div class="main">
<!-- ── DASHBOARD TAB ── -->
<div id="tab-dashboard" class="tab-pane active">
<div class="hero fade-in">
<div class="hero-eyebrow">Corrective Performance Action System</div>
<h1>Employee <span>Compliance</span> Dashboard</h1>
<p>Real-time visibility into workforce disciplinary standing. Track violations, monitor tier escalations, and generate signed documentation — all in one place.</p>
<div class="hero-stats">
<div class="hero-stat"><div class="val">47</div><div class="lbl">Total Employees</div></div>
<div class="hero-stat-divider"></div>
<div class="hero-stat"><div class="val">23</div><div class="lbl">Active Violations (90d)</div></div>
<div class="hero-stat-divider"></div>
<div class="hero-stat"><div class="val">3</div><div class="lbl">At-Risk (Tier 3+)</div></div>
<div class="hero-stat-divider"></div>
<div class="hero-stat"><div class="val">91%</div><div class="lbl">In Good Standing</div></div>
</div>
</div>
<div class="kpi-row">
<div class="kpi-card fade-in">
<div class="kpi-label">New This Week</div>
<div class="kpi-val">6</div>
<div class="kpi-sub">+2 vs prior week</div>
<div class="kpi-accent kpi-accent-gold"></div>
</div>
<div class="kpi-card fade-in">
<div class="kpi-label">Tier 3+ Employees</div>
<div class="kpi-val">3</div>
<div class="kpi-sub">Requires attention</div>
<div class="kpi-accent kpi-accent-red"></div>
</div>
<div class="kpi-card fade-in">
<div class="kpi-label">PDFs Generated</div>
<div class="kpi-val">18</div>
<div class="kpi-sub">This month</div>
<div class="kpi-accent kpi-accent-blue"></div>
</div>
<div class="kpi-card fade-in">
<div class="kpi-label">Expiring (30d)</div>
<div class="kpi-val">9</div>
<div class="kpi-sub">Points rolling off</div>
<div class="kpi-accent kpi-accent-green"></div>
</div>
</div>
<div class="two-col">
<!-- Employee Roster -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">Employee Roster</span>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">47 total</span>
</div>
<table class="emp-table">
<thead><tr><th>Employee</th><th>Dept</th><th>Standing</th></tr></thead>
<tbody>
<tr><td><div class="emp-name">Marcus T.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Operations</div></td><td><span class="tier-badge tier-4">22 pts — Tier 4</span></td></tr>
<tr><td><div class="emp-name">Janelle R.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Production</div></td><td><span class="tier-badge tier-3">17 pts — Tier 3</span></td></tr>
<tr><td><div class="emp-name">Devon H.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Operations</div></td><td><span class="tier-badge tier-3">15 pts — Tier 3</span></td></tr>
<tr><td><div class="emp-name">Priya S.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Impl &amp; Support</div></td><td><span class="tier-badge tier-2">12 pts — Tier 2</span></td></tr>
<tr><td><div class="emp-name">Carlos M.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Production</div></td><td><span class="tier-badge tier-1">7 pts — Tier 1</span></td></tr>
<tr><td><div class="emp-name">Aisha W.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Administrative</div></td><td><span class="tier-badge tier-1">5 pts — Tier 1</span></td></tr>
<tr><td><div class="emp-name">Tom B.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Design &amp; Content</div></td><td><span class="tier-badge tier-0">2 pts — Elite</span></td></tr>
<tr><td><div class="emp-name">Sandra K.</div></td><td><div style="font-size:11px;color:var(--text-muted);">Business Dev</div></td><td><span class="tier-badge tier-0">0 pts — Elite</span></td></tr>
</tbody>
</table>
</div>
<!-- Recent Violations -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">Recent Violations</span>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">Last 7 days</span>
</div>
<div>
<div class="viol-item">
<div class="viol-dot viol-dot-red"></div>
<div class="viol-info">
<div class="viol-name">Marcus T. <span class="repeat-tag">☆ REPEAT</span></div>
<div class="viol-type">Unauthorized Absence — Operations</div>
<div class="viol-meta"><span>Mar 6</span><span>·</span><span>D. Williams</span></div>
</div>
<div class="viol-pts">+5</div>
</div>
<div class="viol-item">
<div class="viol-dot viol-dot-red"></div>
<div class="viol-info">
<div class="viol-name">Janelle R.</div>
<div class="viol-type">Insubordination — Production</div>
<div class="viol-meta"><span>Mar 5</span><span>·</span><span>K. Thompson</span></div>
</div>
<div class="viol-pts">+4</div>
</div>
<div class="viol-item">
<div class="viol-dot viol-dot-yellow"></div>
<div class="viol-info">
<div class="viol-name">Devon H.</div>
<div class="viol-type">Tardiness (3×) — Operations</div>
<div class="viol-meta"><span>Mar 4</span><span>·</span><span>D. Williams</span></div>
</div>
<div class="viol-pts">+3</div>
</div>
<div class="viol-item">
<div class="viol-dot viol-dot-yellow"></div>
<div class="viol-info">
<div class="viol-name">Carlos M.</div>
<div class="viol-type">Cell Phone Policy — Production</div>
<div class="viol-meta"><span>Mar 3</span><span>·</span><span>K. Thompson</span></div>
</div>
<div class="viol-pts">+2</div>
</div>
<div class="viol-item">
<div class="viol-dot viol-dot-yellow"></div>
<div class="viol-info">
<div class="viol-name">Priya S.</div>
<div class="viol-type">Dress Code Violation — Impl &amp; Support</div>
<div class="viol-meta"><span>Mar 2</span><span>·</span><span>M. Johnson</span></div>
</div>
<div class="viol-pts">+1</div>
</div>
<div class="viol-item">
<div class="viol-dot viol-dot-green"></div>
<div class="viol-info">
<div class="viol-name">Aisha W.</div>
<div class="viol-type">Late Return from Break — Administrative</div>
<div class="viol-meta"><span>Mar 1</span><span>·</span><span>S. Martinez</span></div>
</div>
<div class="viol-pts">+1</div>
</div>
</div>
</div>
</div>
<div class="two-col">
<!-- Dept Breakdown -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">Violations by Department</span>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">90-day window</span>
</div>
<div style="padding:8px 0;">
<div class="dept-row"><div class="dept-name">Operations</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:88%"></div></div><div class="dept-count">8</div></div>
<div class="dept-row"><div class="dept-name">Production</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:66%"></div></div><div class="dept-count">6</div></div>
<div class="dept-row"><div class="dept-name">Impl &amp; Support</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:44%"></div></div><div class="dept-count">4</div></div>
<div class="dept-row"><div class="dept-name">Administrative</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:22%"></div></div><div class="dept-count">2</div></div>
<div class="dept-row"><div class="dept-name">Business Dev</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:11%"></div></div><div class="dept-count">1</div></div>
<div class="dept-row"><div class="dept-name">Design &amp; Content</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:11%"></div></div><div class="dept-count">1</div></div>
<div class="dept-row"><div class="dept-name">Executive</div><div class="dept-bar-track"><div class="dept-bar-fill" style="width:0%"></div></div><div class="dept-count">0</div></div>
</div>
</div>
<!-- Audit Log -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">Audit Log</span>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">System activity</span>
</div>
<div>
<div class="audit-item"><div class="audit-time">03/06 2:14p</div><div class="audit-action"><strong>Violation #41</strong> created — Marcus T.</div><div class="audit-pts">+5 pts</div></div>
<div class="audit-item"><div class="audit-time">03/06 2:15p</div><div class="audit-action">PDF generated for <strong>Violation #41</strong></div><div class="audit-pts"></div></div>
<div class="audit-item"><div class="audit-time">03/05 9:40a</div><div class="audit-action"><strong>Violation #40</strong> created — Janelle R.</div><div class="audit-pts">+4 pts</div></div>
<div class="audit-item"><div class="audit-time">03/04 11:20a</div><div class="audit-action">Employee <strong>Devon H.</strong> record updated</div><div class="audit-pts"></div></div>
<div class="audit-item"><div class="audit-time">03/04 8:55a</div><div class="audit-action"><strong>Violation #39</strong> created — Devon H.</div><div class="audit-pts">+3 pts</div></div>
<div class="audit-item"><div class="audit-time">03/03 3:30p</div><div class="audit-action"><strong>Violation #38</strong> amended — Carlos M.</div><div class="audit-pts">1 pt</div></div>
<div class="audit-item"><div class="audit-time">03/02 1:05p</div><div class="audit-action"><strong>Duplicate record</strong> merged — R. Johnson</div><div class="audit-pts"></div></div>
</div>
</div>
</div>
<!-- CPAS Tier Scale -->
<div style="margin-bottom:24px;">
<div class="section-header">
<div class="section-title">CPAS Tier Scale</div>
</div>
<div class="tier-timeline">
<div class="tier-seg ts-0"><div class="ts-pts">04</div><div class="ts-label">Elite<br/>Standing</div></div>
<div class="tier-seg ts-1"><div class="ts-pts">59</div><div class="ts-label">Tier 1<br/>Realignment</div></div>
<div class="tier-seg ts-2"><div class="ts-pts">1014</div><div class="ts-label">Tier 2<br/>Admin Lockdown</div></div>
<div class="tier-seg ts-3"><div class="ts-pts">1519</div><div class="ts-label">Tier 3<br/>Verification</div></div>
<div class="tier-seg ts-4"><div class="ts-pts">2024</div><div class="ts-label">Tier 4<br/>Risk Mitigation</div></div>
<div class="tier-seg ts-5"><div class="ts-pts">2529</div><div class="ts-label">Tier 5<br/>Final Decision</div></div>
<div class="tier-seg ts-6"><div class="ts-pts">30+</div><div class="ts-label">Tier 6<br/>Separation</div></div>
</div>
</div>
<!-- System Capabilities -->
<div class="section-header"><div class="section-title">System Capabilities</div></div>
<div class="features-grid">
<div class="feature-card fade-in"><span class="feature-icon"></span><div class="feature-title">Repeat Offense Detection</div><div class="feature-desc">Automatically flags prior violations for the same type and escalates point recommendations per recidivist policy.</div></div>
<div class="feature-card fade-in"><span class="feature-icon">📄</span><div class="feature-title">One-Click PDF Generation</div><div class="feature-desc">Generates signed, professional violation documents instantly — with or without employee acknowledgment signatures.</div></div>
<div class="feature-card fade-in"><span class="feature-icon">📀</span><div class="feature-title">Duplicate Record Merge</div><div class="feature-desc">Consolidate duplicate employee records while preserving full violation history under the canonical profile.</div></div>
<div class="feature-card fade-in"><span class="feature-icon">🕊</span><div class="feature-title">90-Day Rolling Window</div><div class="feature-desc">Points automatically expire after 90 days. Active standing reflects only the current compliance window.</div></div>
<div class="feature-card fade-in"><span class="feature-icon">🏷️</span><div class="feature-title">Tier Escalation Warnings</div><div class="feature-desc">Real-time alerts when a new violation would push an employee across a tier boundary before you submit.</div></div>
<div class="feature-card fade-in"><span class="feature-icon">🗂️</span><div class="feature-title">Full Audit Trail</div><div class="feature-desc">Every create, amendment, merge, and PDF generation is logged with timestamp and operator attribution.</div></div>
</div>
</div><!-- /tab-dashboard -->
<!-- ── VIOLATION FORM TAB ── -->
<div id="tab-violations" class="tab-pane">
<div style="margin-bottom:20px;">
<div style="background:#181200;border:1px solid var(--gold-dk);border-radius:8px;padding:12px 18px;font-size:12px;color:var(--gold-lt);font-family:'DM Mono',monospace;letter-spacing:0.4px;">
⚡ DEMO VIEW — Form fields shown with sample data. Submission is disabled in demo mode.
</div>
</div>
<div class="form-preview">
<div class="form-section">
<div class="form-section-title">Employee Information</div>
<div style="margin-bottom:16px;">
<div class="form-label" style="margin-bottom:6px;">Quick-Select Existing Employee:</div>
<div class="form-input filled" style="display:flex;align-items:center;justify-content:space-between;">
<span>Marcus Thompson — Operations</span><span style="color:var(--text-muted);font-size:11px;"></span>
</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:12px;color:var(--text-dim);font-weight:600;">Current Standing:</span>
<span class="tier-badge tier-4">22 pts — Tier 4 · Risk Mitigation</span>
<span style="font-size:11px;color:var(--text-muted);">4 violations in last 90 days</span>
</div>
</div>
<div class="form-grid">
<div class="form-item"><div class="form-label">Employee Name:</div><div class="form-input filled">Marcus Thompson</div></div>
<div class="form-item"><div class="form-label">Department:</div><div class="form-input filled" style="display:flex;align-items:center;justify-content:space-between;"><span>Operations</span><span style="color:var(--text-muted);font-size:11px;"></span></div></div>
<div class="form-item"><div class="form-label">Supervisor Name:</div><div class="form-input filled">D. Williams</div></div>
<div class="form-item"><div class="form-label">Witness Name (Officer):</div><div class="form-input">Officer Name</div></div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Violation Details</div>
<div class="form-grid">
<div class="form-item" style="grid-column:1 / -1;">
<div class="form-label">Violation Type:</div>
<div class="form-input filled" style="display:flex;align-items:center;justify-content:space-between;">
<span>Unauthorized Absence ☆ 2x in 90 days</span><span style="color:var(--text-muted);font-size:11px;"></span>
</div>
<div style="background:#141623;border:1px solid var(--border-lt);border-radius:4px;padding:10px;font-size:12px;color:var(--text-dim);margin-top:6px;">
<strong>Unauthorized Absence</strong> <span class="repeat-tag">☆ Repeat — 2x prior</span><br/>
Absence from scheduled work without prior approval or acceptable documentation.<br/>
<span style="font-size:10px;color:#a0a3ba;">Chapter 4, Section 4.1 — Attendance &amp; Punctuality</span>
</div>
<div style="background:#3b2e00;border:1px solid var(--gold-dk);border-radius:4px;padding:8px 12px;margin-top:6px;font-size:12px;color:#ffdf8a;">
<strong>Repeat offense detected.</strong> Point slider set to maximum (5 pts) per recidivist policy. Adjust if needed.
</div>
</div>
<div class="form-item"><div class="form-label">Incident Date:</div><div class="form-input filled">2026-03-06</div></div>
</div>
<div style="background:#2d1a00;border:1px solid #a06000;border-radius:6px;padding:12px 16px;margin-top:16px;font-size:13px;color:#ffc107;">
<strong>Tier escalation warning:</strong> Adding 5 pts will bring Marcus to <strong>27 pts (Tier 5 — Final Decision)</strong>. This is one tier below Separation. Review carefully.
</div>
<div style="background:#181200;border:2px solid var(--gold);padding:14px;border-radius:6px;margin:16px 0 0;text-align:center;">
<div style="color:#ffdf8a;font-weight:700;margin-bottom:8px;">CPAS Point Assessment</div>
<div style="font-size:13px;color:var(--text-dim);">Unauthorized Absence: 35 Points</div>
<div style="width:100%;height:6px;background:var(--border-lt);border-radius:3px;margin:12px 0 4px;overflow:hidden;"><div style="width:100%;height:100%;background:linear-gradient(90deg,var(--gold-dk),var(--gold));border-radius:3px;"></div></div>
<div class="point-val">5 Points</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;">Adjust to reflect severity and context</div>
</div>
</div>
<div style="background:var(--bg-section);border-left:4px solid #2196F3;padding:20px 24px;margin:20px;border-radius:4px;border:1px solid var(--border-lt);">
<div style="font-family:'Syne',sans-serif;font-size:16px;font-weight:700;margin-bottom:8px;">Employee Acknowledgment</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:14px;line-height:1.6;">If the employee is present and acknowledges receipt of this violation, enter their name and the date below.</div>
<div class="form-grid">
<div class="form-item"><div class="form-label">Acknowledged By:</div><div class="form-input">Employee's printed name</div></div>
<div class="form-item"><div class="form-label">Acknowledgment Date:</div><div class="form-input">yyyy-mm-dd</div></div>
</div>
</div>
<div style="display:flex;gap:14px;justify-content:center;padding:20px 20px 28px;">
<button style="padding:14px 36px;font-size:15px;font-weight:700;border:none;border-radius:6px;cursor:not-allowed;background:linear-gradient(135deg,#d4af37,#ffdf8a);color:#000;text-transform:uppercase;opacity:0.5;">Submit Violation</button>
<button style="padding:14px 36px;font-size:15px;font-weight:700;border:1px solid var(--border-lt);border-radius:6px;cursor:not-allowed;background:#050608;color:var(--text);text-transform:uppercase;opacity:0.5;">Clear Form</button>
</div>
</div>
</div><!-- /tab-violations -->
</div><!-- /main -->
<footer>
<div class="footer-left">
<div class="footer-brand">CPAS Tracker</div>
<div class="footer-divider"></div>
<div class="footer-copy">© 2026 Jason Stedwell</div>
<div class="footer-divider"></div>
<div style="font-size:11px;color:var(--text-muted);">DEMO BUILD — All data synthetic</div>
</div>
<div class="footer-right">
<a class="footer-gitea" href="https://git.alwisp.com/jason/cpas" target="_blank" rel="noopener">
<!-- Gitea logo SVG -->
<svg viewBox="0 0 640 640" xmlns="http://www.w3.org/2000/svg">
<path d="M321.6 3.2C146.4 3.2 3.2 146.4 3.2 321.6c0 141.6 91.8 261.9 219.7 304.1 16.1 3 22-7 22-15.5 0-7.6-.3-32.8-.4-59.5-89.5 19.4-108.4-38.4-108.4-38.4-14.6-37.2-35.7-47.1-35.7-47.1-29.2-20 2.2-19.6 2.2-19.6 32.3 2.3 49.3 33.1 49.3 33.1 28.7 49.2 75.3 35 93.7 26.7 2.9-20.8 11.2-35 20.4-43-71.4-8.1-146.5-35.7-146.5-158.9 0-35.1 12.5-63.8 33.1-86.3-3.3-8.1-14.4-40.8 3.1-85.1 0 0 27-8.7 88.4 32.9 25.6-7.1 53.1-10.7 80.4-10.8 27.3.1 54.8 3.7 80.5 10.8 61.3-41.6 88.3-32.9 88.3-32.9 17.6 44.3 6.5 77 3.2 85.1 20.6 22.5 33 51.2 33 86.3 0 123.5-75.2 150.7-146.8 158.7 11.5 10 21.8 29.7 21.8 59.8 0 43.2-.4 78-0.4 88.6 0 8.6 5.8 18.6 22.1 15.5C524.8 583.2 616.8 463.1 616.8 321.6 616.8 146.4 473.6 3.2 298.4 3.2z"/>
</svg>
jason/cpas
</a>
<div class="footer-divider"></div>
<div class="footer-ticker">
<div class="footer-ticker-dot"></div>
<div class="footer-ticker-label">Dev Time</div>
<div class="footer-ticker-time" id="dev-ticker"></div>
</div>
</div>
</footer>
<script>
function switchTab(name, el) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
el.classList.add('active');
}
// ── DEV TIME TICKER ──────────────────────────────────────────────────────────
// Base dev time calculated from real commit sessions (30-min gap = new session).
// Each session timed as (last_commit - first_commit) + 15min overhead.
//
// Session 1: 2026-03-06 11:33 → 12:05 = 32min + 15 = 47min
// Session 2: 2026-03-06 12:19 → 18:00 = 341min + 15 = 356min
// Session 3: 2026-03-06 23:18 → 23:41 = 23min + 15 = 38min
// Session 4: 2026-03-07 09:22 → 09:53 = 31min + 15 = 46min
// Session 5: 2026-03-07 18:31 → 19:02 = 31min + 15 = 46min
// Session 6: 2026-03-07 21:28 → 22:02 = 34min + 15 = 49min
// Session 7: 2026-03-07 23:13 → 23:59 = 46min + 15 = 61min
// Session 8: 2026-03-08 00:11 → 00:12 = 1min + 15 = 16min
// Total: 659min = 39,540 seconds
//
// Anchor: last commit timestamp 2026-03-08T06:12:11Z (UTC)
// Ticker ticks up every second from that base.
const BASE_SECONDS = 39540;
const ANCHOR_UTC = new Date('2026-03-08T06:12:11Z').getTime();
function formatDevTime(totalSec) {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
}
function updateTicker() {
const elapsed = Math.floor((Date.now() - ANCHOR_UTC) / 1000);
const total = BASE_SECONDS + Math.max(0, elapsed);
document.getElementById('dev-ticker').textContent = formatDevTime(total);
}
updateTicker();
setInterval(updateTicker, 1000);
</script>
</body>
</html>
@@ -0,0 +1,24 @@
{
"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",
"puppeteer-core": "^22.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
@@ -0,0 +1,47 @@
const puppeteer = require('puppeteer-core');
const buildHtml = require('./template');
/**
* Renders the violation document HTML via Puppeteer and returns a PDF buffer.
* Uses the system Chromium installed in the Alpine image (no separate download).
* @param {object} violation - Row from violations JOIN employees
* @param {object} score - Row from active_cpas_scores
* @returns {Buffer}
*/
async function generatePdf(violation, score) {
const html = buildHtml(violation, score);
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
headless: 'new',
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
margin: {
top: '0.35in',
bottom: '0.35in',
left: '0.4in',
right: '0.4in',
},
displayHeaderFooter: false,
});
return pdf;
} finally {
await browser.close();
}
}
module.exports = generatePdf;
@@ -0,0 +1,385 @@
const fs = require('fs');
const path = require('path');
// Load logo from disk once at startup and convert to base64 data URI
// In Docker: /app/client/dist/static/mpm-logo.png
// In dev: ./client/public/static/mpm-logo.png (or dist after build)
let LOGO_DATA_URI = '';
const logoPaths = [
path.join(__dirname, '..', 'client', 'dist', 'static', 'mpm-logo.png'),
path.join(__dirname, '..', 'client', 'public', 'static', 'mpm-logo.png'),
];
for (const p of logoPaths) {
try {
const buf = fs.readFileSync(p);
LOGO_DATA_URI = `data:image/png;base64,${buf.toString('base64')}`;
console.log('[PDF] Logo loaded from', p);
break;
} catch (_) { /* try next path */ }
}
if (!LOGO_DATA_URI) console.warn('[PDF] Logo not found — PDF header will have no logo');
const TIERS = [
{ min: 0, max: 4, label: 'Tier 0\u20131 \u2014 Elite Standing', color: '#16a34a', bg: '#f0fdf4' },
{ min: 5, max: 9, label: 'Tier 1 \u2014 Realignment', color: '#854d0e', bg: '#fefce8' },
{ min: 10, max: 14, label: 'Tier 2 \u2014 Administrative Lockdown', color: '#b45309', bg: '#fff7ed' },
{ min: 15, max: 19, label: 'Tier 3 \u2014 Verification', color: '#c2410c', bg: '#fff7ed' },
{ min: 20, max: 24, label: 'Tier 4 \u2014 Risk Mitigation', color: '#b91c1c', bg: '#fef2f2' },
{ min: 25, max: 29, label: 'Tier 5 \u2014 Final Decision', color: '#991b1b', bg: '#fef2f2' },
{ min: 30, max: 999, label: 'Tier 6 \u2014 Separation', color: '#ffffff', bg: '#7f1d1d' },
];
function getTier(pts) {
return TIERS.find(t => pts >= t.min && pts <= t.max) || TIERS[0];
}
function fmt(d) {
if (!d) return '\u2014';
return new Date(d + 'T12:00:00').toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
timeZone: 'America/Chicago',
});
}
function fmtDT(d, t) { return t ? `${fmt(d)} at ${t}` : fmt(d); }
function buildHtml(v, score) {
const priorPts = score.active_points || 0;
const priorTier = getTier(priorPts);
const newTotal = priorPts + v.points;
const newTier = getTier(newTotal);
const escalated = priorTier.label !== newTier.label;
const genAt = new Date().toLocaleString('en-US', {
timeZone: 'America/Chicago', dateStyle: 'full', timeStyle: 'short',
});
const docId = `CPAS-${v.id.toString().padStart(5, '0')}`;
// Acknowledgment: if acknowledged_by is set, show filled data instead of blank sig line
const hasAck = !!v.acknowledged_by;
const ackName = v.acknowledged_by || '';
const ackDate = v.acknowledged_date ? fmt(v.acknowledged_date) : '';
const logoTag = LOGO_DATA_URI
? `<img src="${LOGO_DATA_URI}" class="logo" />`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', Arial, sans-serif;
font-size: 13px;
color: #1a1a2e;
background: #fff;
line-height: 1.5;
}
.header {
background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 60%, #16213e 100%);
padding: 24px 36px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #d4af37;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo { height: 36px; }
.header-title { font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: 0.3px; }
.header-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; letter-spacing: 0.5px; text-transform: uppercase; }
.header-right { text-align: right; }
.doc-id { font-size: 13px; font-weight: 700; color: #d4af37; letter-spacing: 0.5px; }
.doc-meta { font-size: 10px; color: #64748b; margin-top: 4px; }
.confidential-bar {
background: #fef2f2; border-bottom: 1px solid #fecaca;
padding: 7px 36px; font-size: 11px; font-weight: 700; color: #991b1b;
letter-spacing: 0.8px; text-transform: uppercase; text-align: center;
}
.body { padding: 28px 36px; }
.section { margin-bottom: 24px; }
.section-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #64748b; }
.section-rule { flex: 1; height: 1px; background: #e2e8f0; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; }
.field-grid.single { grid-template-columns: 1fr; }
.field { padding: 0; }
.field-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 2px; }
.field-value { font-size: 13px; color: #1e293b; font-weight: 500; }
.field-value.prominent { font-size: 15px; font-weight: 700; color: #0f172a; }
.detail-box {
background: #f8fafc; border: 1px solid #e2e8f0; border-left: 4px solid #667eea;
border-radius: 6px; padding: 14px 16px; margin-top: 12px; font-size: 12px; color: #374151; line-height: 1.6;
}
.detail-box-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-bottom: 6px; }
.score-card {
display: flex; align-items: center; gap: 0; background: #f8fafc;
border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; margin-top: 4px;
}
.score-cell { flex: 1; padding: 18px 16px; text-align: center; border-right: 1px solid #e2e8f0; }
.score-cell:last-child { border-right: none; }
.score-cell.operator { flex: 0 0 48px; font-size: 24px; font-weight: 200; color: #cbd5e1; }
.score-num { font-size: 32px; font-weight: 800; line-height: 1; }
.score-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; margin-top: 4px; }
.tier-badge { display: inline-block; margin-top: 8px; padding: 3px 10px; border-radius: 12px; font-size: 10px; font-weight: 700; letter-spacing: 0.3px; }
.points-pill {
display: inline-flex; align-items: center; gap: 10px;
background: #fffbeb; border: 2px solid #d4af37; border-radius: 8px;
padding: 12px 24px; margin-bottom: 16px;
}
.points-pill-num { font-size: 42px; font-weight: 900; color: #d4af37; line-height: 1; }
.points-pill-label { font-size: 12px; color: #92400e; line-height: 1.4; }
.points-pill-label strong { display: block; font-size: 14px; }
.escalation-alert {
background: #fef9c3; border: 1.5px solid #eab308; border-radius: 8px;
padding: 12px 16px; margin-top: 14px; font-size: 12px; color: #713f12;
display: flex; align-items: center; gap: 10px;
}
.escalation-icon { font-size: 18px; }
.tier-table { width: 100%; border-collapse: collapse; }
.tier-table th { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; text-align: left; padding: 6px 12px; border-bottom: 2px solid #e2e8f0; }
.tier-table td { padding: 7px 12px; font-size: 12px; border-bottom: 1px solid #f1f5f9; }
.tier-table tr.current-tier td { background: #fffbeb; font-weight: 700; }
.tier-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.notice {
background: #eff6ff; border-left: 4px solid #3b82f6; border-radius: 0 6px 6px 0;
padding: 12px 16px; font-size: 11.5px; color: #1e40af; line-height: 1.6;
}
.sig-intro { font-size: 11.5px; color: #475569; line-height: 1.7; margin-bottom: 28px; }
.sig-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; }
.sig-line { border-bottom: 1.5px solid #334155; margin-bottom: 8px; min-height: 52px; }
.sig-line-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #64748b; }
.sig-date-line { border-bottom: 1.5px solid #334155; margin-bottom: 8px; margin-top: 20px; min-height: 36px; }
.sig-filled { font-size: 14px; font-weight: 600; color: #1e293b; padding-bottom: 6px; border-bottom: 1.5px solid #334155; margin-bottom: 8px; min-height: 52px; display: flex; align-items: flex-end; }
.sig-date-filled { font-size: 13px; color: #1e293b; padding-bottom: 6px; border-bottom: 1.5px solid #334155; margin-bottom: 8px; margin-top: 20px; min-height: 36px; display: flex; align-items: flex-end; }
.ack-badge { display: inline-block; background: #dcfce7; color: #166534; border: 1px solid #86efac; border-radius: 6px; padding: 2px 8px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; margin-left: 10px; }
.footer-bar {
margin-top: 32px; padding: 10px 0 0; border-top: 1px solid #e2e8f0;
font-size: 10px; color: #94a3b8; display: flex; justify-content: space-between;
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
${logoTag}
<div>
<div class="header-title">CPAS Violation Record</div>
<div class="header-sub">Comprehensive Professional Accountability System</div>
</div>
</div>
<div class="header-right">
<div class="doc-id">${docId}</div>
<div class="doc-meta">Generated ${genAt}</div>
</div>
</div>
<div class="confidential-bar">\u26D1 Confidential \u2014 Authorized HR &amp; Management Use Only</div>
<div class="body">
<!-- Employee -->
<div class="section">
<div class="section-header">
<div class="section-title">Employee Information</div>
<div class="section-rule"></div>
</div>
<div class="field-grid">
<div class="field">
<div class="field-label">Employee Name</div>
<div class="field-value prominent">${v.employee_name}</div>
</div>
<div class="field">
<div class="field-label">Department</div>
<div class="field-value">${v.department || '\u2014'}</div>
</div>
<div class="field">
<div class="field-label">Supervisor</div>
<div class="field-value">${v.supervisor || '\u2014'}</div>
</div>
<div class="field">
<div class="field-label">Witness / Documenting Officer</div>
<div class="field-value">${v.witness_name || '\u2014'}</div>
</div>
</div>
</div>
<!-- Violation -->
<div class="section">
<div class="section-header">
<div class="section-title">Violation Details</div>
<div class="section-rule"></div>
</div>
<div class="field-grid">
<div class="field">
<div class="field-label">Violation</div>
<div class="field-value prominent">${v.violation_name}</div>
</div>
<div class="field">
<div class="field-label">Category</div>
<div class="field-value">${v.category}</div>
</div>
<div class="field">
<div class="field-label">Incident Date / Time</div>
<div class="field-value">${fmtDT(v.incident_date, v.incident_time)}</div>
</div>
<div class="field">
<div class="field-label">Submitted By</div>
<div class="field-value">${v.submitted_by || 'System'}</div>
</div>
${v.location ? `
<div class="field" style="grid-column: 1 / -1;">
<div class="field-label">Location / Context</div>
<div class="field-value">${v.location}</div>
</div>` : ''}
</div>
${v.details ? `
<div class="detail-box">
<div class="detail-box-label">Incident Notes</div>
${v.details}
</div>` : ''}
</div>
<!-- Points -->
<div class="section">
<div class="section-header">
<div class="section-title">CPAS Point Assessment</div>
<div class="section-rule"></div>
</div>
<div class="points-pill">
<div class="points-pill-num">${v.points}</div>
<div class="points-pill-label">
<strong>Points Assessed</strong>
This violation
</div>
</div>
<div class="score-card">
<div class="score-cell">
<div class="score-num" style="color:${priorTier.color};">${priorPts}</div>
<div class="score-label">Prior Active Points</div>
<span class="tier-badge" style="background:${priorTier.bg}; color:${priorTier.color};">
${priorTier.label}
</span>
</div>
<div class="score-cell operator">+</div>
<div class="score-cell">
<div class="score-num" style="color:#d4af37;">${v.points}</div>
<div class="score-label">This Violation</div>
</div>
<div class="score-cell operator">=</div>
<div class="score-cell">
<div class="score-num" style="color:${newTier.color};">${newTotal}</div>
<div class="score-label">New Active Total</div>
<span class="tier-badge" style="background:${newTier.bg}; color:${newTier.color};">
${newTier.label}
</span>
</div>
</div>
${escalated ? `
<div class="escalation-alert">
<span class="escalation-icon">\u26A0</span>
<div>
<strong>Tier Escalation:</strong>
This violation advances the employee from <strong>${priorTier.label}</strong>
to <strong>${newTier.label}</strong>.
</div>
</div>` : ''}
</div>
<!-- Tier Reference -->
<div class="section">
<div class="section-header">
<div class="section-title">CPAS Tier Reference</div>
<div class="section-rule"></div>
</div>
<table class="tier-table">
<thead>
<tr>
<th>Points</th>
<th>Tier &amp; Standing</th>
</tr>
</thead>
<tbody>
${TIERS.map(t => {
const active = newTotal >= t.min && newTotal <= t.max;
const range = t.min === 30 ? '30+' : `${t.min}\u2013${t.max}`;
return `<tr class="${active ? 'current-tier' : ''}">
<td>${active ? '\u25B6 ' : ''}${range}</td>
<td>
<span class="tier-dot" style="background:${t.color === '#ffffff' ? t.bg : t.color};"></span>
${t.label}
${active ? '<strong> \u2190 Current</strong>' : ''}
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
<!-- Notice -->
<div class="notice" style="margin-bottom:24px;">
<strong>Employee Notice:</strong> CPAS points remain active for a rolling 90-day period from the date of each incident.
Accumulation of points may result in tier escalation and associated consequences as outlined in the Employee Handbook.
The employee may submit a written response within 5 business days of receiving this document.
</div>
<!-- Signatures -->
<div class="section">
<div class="section-header">
<div class="section-title">Acknowledgement &amp; Signatures${hasAck ? '<span class="ack-badge">Acknowledged</span>' : ''}</div>
<div class="section-rule"></div>
</div>
<p class="sig-intro">
By signing below, the employee acknowledges receipt of this violation record.
Acknowledgement does not imply agreement with the violation as documented.
</p>
<div class="sig-grid">
<div class="sig-block">
${hasAck
? `<div class="sig-filled">${ackName}</div>`
: '<div class="sig-line"></div>'}
<div class="sig-line-label">Employee Signature</div>
${hasAck && ackDate
? `<div class="sig-date-filled">${ackDate}</div>`
: '<div class="sig-date-line"></div>'}
<div class="sig-line-label">Date</div>
</div>
<div class="sig-block">
<div class="sig-line"></div>
<div class="sig-line-label">Supervisor / Documenting Officer Signature</div>
<div class="sig-date-line"></div>
<div class="sig-line-label">Date</div>
</div>
</div>
</div>
<div class="footer-bar">
<span>${docId} &nbsp;\u00B7&nbsp; ${v.employee_name} &nbsp;\u00B7&nbsp; Incident: ${v.incident_date}</span>
<span>Message Point Media \u2014 Internal Use Only</span>
</div>
</div>
</body>
</html>`;
}
module.exports = buildHtml;
+546
View File
@@ -0,0 +1,546 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db/database');
const generatePdf = require('./pdf/generator');
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')));
// ── Demo static route ─────────────────────────────────────────────────────────
// Serves the standalone stakeholder demo page at /demo/index.html
// Must be registered before the SPA catch-all below.
app.use('/demo', express.static(path.join(__dirname, 'demo')));
// ── Audit helper ─────────────────────────────────────────────────────────────
function audit(action, entityType, entityId, performedBy, details) {
try {
db.prepare(`
INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details)
VALUES (?, ?, ?, ?, ?)
`).run(action, entityType, entityId || null, performedBy || null,
typeof details === 'object' ? JSON.stringify(details) : (details || null));
} catch (e) {
console.error('[AUDIT]', e.message);
}
}
// ── Version info (written by Dockerfile at build time) ───────────────────────
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
try {
BUILD_VERSION = require('./client/dist/version.json');
} catch (_) { /* pre-build or local dev — stub values are fine */ }
// Health
app.get('/api/health', (req, res) => res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: BUILD_VERSION,
}));
// ── Employees ────────────────────────────────────────────────────────────────
app.get('/api/employees', (req, res) => {
const rows = db.prepare('SELECT id, name, department, supervisor, notes 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);
audit('employee_created', 'employee', result.lastInsertRowid, null, { name });
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
});
// ── Employee Edit ────────────────────────────────────────────────────────────
// PATCH /api/employees/:id — update name, department, supervisor, or notes
app.patch('/api/employees/:id', (req, res) => {
const id = parseInt(req.params.id);
const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
const { name, department, supervisor, notes, performed_by } = req.body;
// Prevent name collision with a different employee
if (name && name.trim() !== emp.name) {
const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id);
if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id });
}
const newName = (name || emp.name).trim();
const newDept = department !== undefined ? (department || null) : emp.department;
const newSupervisor = supervisor !== undefined ? (supervisor || null) : emp.supervisor;
const newNotes = notes !== undefined ? (notes || null) : emp.notes;
db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ?, notes = ? WHERE id = ?')
.run(newName, newDept, newSupervisor, newNotes, id);
audit('employee_edited', 'employee', id, performed_by, {
before: { name: emp.name, department: emp.department, supervisor: emp.supervisor, notes: emp.notes },
after: { name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes },
});
res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes });
});
// ── Employee Merge ───────────────────────────────────────────────────────────
// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source
app.post('/api/employees/:id/merge', (req, res) => {
const targetId = parseInt(req.params.id);
const { source_id, performed_by } = req.body;
if (!source_id) return res.status(400).json({ error: 'source_id is required' });
const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId);
const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id);
if (!target) return res.status(404).json({ error: 'Target employee not found' });
if (!source) return res.status(404).json({ error: 'Source employee not found' });
if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' });
const mergeTransaction = db.transaction(() => {
// Move all violations
const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id);
// Delete the source employee
db.prepare('DELETE FROM employees WHERE id = ?').run(source_id);
return moved.changes;
});
const violationsMoved = mergeTransaction();
audit('employee_merged', 'employee', targetId, performed_by, {
source: { id: source.id, name: source.name },
target: { id: target.id, name: target.name },
violations_reassigned: violationsMoved,
});
res.json({ success: true, violations_reassigned: violationsMoved });
});
// ── Employee notes (PATCH shorthand) ─────────────────────────────────────────
// PATCH /api/employees/:id/notes — save free-text notes only
app.patch('/api/employees/:id/notes', (req, res) => {
const id = parseInt(req.params.id);
const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
const { notes, performed_by } = req.body;
const newNotes = notes !== undefined ? (notes || null) : emp.notes;
db.prepare('UPDATE employees SET notes = ? WHERE id = ?').run(newNotes, id);
audit('employee_notes_updated', 'employee', id, performed_by, { notes: newNotes });
res.json({ id, notes: newNotes });
});
// Employee score (current snapshot) — includes total violations + negated count
app.get('/api/employees/:id/score', (req, res) => {
const empId = req.params.id;
// Active points from the 90-day rolling view
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId);
// Total violations (all time) and negated count
const totals = db.prepare(`
SELECT
COUNT(*) AS total_violations,
COALESCE(SUM(negated), 0) AS negated_count
FROM violations
WHERE employee_id = ?
`).get(empId);
res.json({
employee_id: empId,
active_points: active ? active.active_points : 0,
violation_count: active ? active.violation_count : 0,
total_violations: totals ? totals.total_violations : 0,
negated_count: totals ? totals.negated_count : 0,
});
});
// ── Expiration Timeline ──────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — active violations sorted by roll-off date
// Returns each active violation with days_remaining until it exits the 90-day window.
app.get('/api/employees/:id/expiration', (req, res) => {
const rows = db.prepare(`
SELECT
v.id,
v.violation_name,
v.violation_type,
v.category,
v.points,
v.incident_date,
DATE(v.incident_date, '+90 days') AS expires_on,
CAST(
JULIANDAY(DATE(v.incident_date, '+90 days')) -
JULIANDAY(DATE('now'))
AS INTEGER
) AS days_remaining
FROM violations v
WHERE v.employee_id = ?
AND v.negated = 0
AND v.incident_date >= DATE('now', '-90 days')
ORDER BY v.incident_date ASC
`).all(req.params.id);
res.json(rows);
});
// Dashboard
app.get('/api/dashboard', (req, res) => {
const rows = db.prepare(`
SELECT e.id, e.name, e.department, e.supervisor,
COALESCE(s.active_points, 0) AS active_points,
COALESCE(s.violation_count,0) AS violation_count
FROM employees e
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
ORDER BY active_points DESC, e.name ASC
`).all();
res.json(rows);
});
// Violation history (per employee) with resolutions + amendment count
app.get('/api/violations/employee/:id', (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const rows = db.prepare(`
SELECT v.*, r.resolution_type, r.details AS resolution_details,
r.resolved_by, r.created_at AS resolved_at,
(SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count
FROM violations v
LEFT JOIN violation_resolutions r ON r.violation_id = v.id
WHERE v.employee_id = ?
ORDER BY v.incident_date DESC, v.created_at DESC
LIMIT ?
`).all(req.params.id, limit);
res.json(rows);
});
// ── Violation amendment history ──────────────────────────────────────────────
app.get('/api/violations/:id/amendments', (req, res) => {
const rows = db.prepare(`
SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC
`).all(req.params.id);
res.json(rows);
});
// Helper: compute prior_active_points at time of insert
function getPriorActivePoints(employeeId, incidentDate) {
const row = db.prepare(
`SELECT COALESCE(SUM(points),0) AS pts
FROM violations
WHERE employee_id = ?
AND negated = 0
AND incident_date >= DATE(?, '-90 days')
AND incident_date < ?`
).get(employeeId, incidentDate, incidentDate);
return row ? row.pts : 0;
}
// POST new violation
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,
acknowledged_by, acknowledged_date
} = req.body;
if (!employee_id || !violation_type || !points || !incident_date) {
return res.status(400).json({ error: 'Missing required fields' });
}
const ptsInt = parseInt(points);
const priorPts = getPriorActivePoints(employee_id, 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,
prior_active_points,
acknowledged_by, acknowledged_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
employee_id, violation_type, violation_name || violation_type,
category || 'General', ptsInt, incident_date,
incident_time || null, location || null,
details || null, submitted_by || null, witness_name || null,
priorPts,
acknowledged_by || null, acknowledged_date || null
);
audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
employee_id, violation_type, points: ptsInt, incident_date,
});
res.status(201).json({ id: result.lastInsertRowid });
});
// ── Violation Amendment (edit) ───────────────────────────────────────────────
// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field
const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_date'];
app.patch('/api/violations/:id/amend', (req, res) => {
const id = parseInt(req.params.id);
const { changed_by, ...updates } = req.body;
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
if (!violation) return res.status(404).json({ error: 'Violation not found' });
if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' });
// Only allow whitelisted fields to be amended
const allowed = Object.fromEntries(
Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k))
);
if (Object.keys(allowed).length === 0) {
return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS });
}
const amendTransaction = db.transaction(() => {
// Build UPDATE
const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', ');
const values = [...Object.values(allowed), id];
db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values);
// Insert an amendment record per changed field
const insertAmendment = db.prepare(`
INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value)
VALUES (?, ?, ?, ?, ?)
`);
for (const [field, newVal] of Object.entries(allowed)) {
const oldVal = violation[field];
if (String(oldVal) !== String(newVal)) {
insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null);
}
}
});
amendTransaction();
audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) });
const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
res.json(updated);
});
// ── Negate a violation ───────────────────────────────────────────────────────
app.patch('/api/violations/:id/negate', (req, res) => {
const { resolution_type, details, resolved_by } = req.body;
const id = req.params.id;
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
if (!violation) return res.status(404).json({ error: 'Violation not found' });
db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id);
const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id);
if (existing) {
db.prepare(`
UPDATE violation_resolutions
SET resolution_type = ?, details = ?, resolved_by = ?, created_at = datetime('now')
WHERE violation_id = ?
`).run(resolution_type || 'Resolved', details || null, resolved_by || null, id);
} else {
db.prepare(`
INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by)
VALUES (?, ?, ?, ?)
`).run(id, resolution_type || 'Resolved', details || null, resolved_by || null);
}
audit('violation_negated', 'violation', id, resolved_by, { resolution_type });
res.json({ success: true });
});
// ── Restore a negated violation ──────────────────────────────────────────────
app.patch('/api/violations/:id/restore', (req, res) => {
const id = req.params.id;
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
if (!violation) return res.status(404).json({ error: 'Violation not found' });
db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id);
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
audit('violation_restored', 'violation', id, req.body?.performed_by, {});
res.json({ success: true });
});
// ── Hard delete a violation ──────────────────────────────────────────────────
app.delete('/api/violations/:id', (req, res) => {
const id = req.params.id;
const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
if (!violation) return res.status(404).json({ error: 'Violation not found' });
db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
db.prepare('DELETE FROM violations WHERE id = ?').run(id);
audit('violation_deleted', 'violation', id, req.body?.performed_by, {
violation_type: violation.violation_type, employee_id: violation.employee_id,
});
res.json({ success: true });
});
// ── Audit log ────────────────────────────────────────────────────────────────
app.get('/api/audit', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const type = req.query.entity_type;
const id = req.query.entity_id;
let sql = 'SELECT * FROM audit_log';
const args = [];
const where = [];
if (type) { where.push('entity_type = ?'); args.push(type); }
if (id) { where.push('entity_id = ?'); args.push(id); }
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
args.push(limit, offset);
res.json(db.prepare(sql).all(...args));
});
// ── Custom Violation Types ────────────────────────────────────────────────────
// Persisted violation type definitions stored in violation_types table.
// type_key is auto-generated (custom_<slug>) to avoid collisions with hardcoded keys.
app.get('/api/violation-types', (req, res) => {
const rows = db.prepare('SELECT * FROM violation_types ORDER BY category ASC, name ASC').all();
res.json(rows.map(r => ({ ...r, fields: JSON.parse(r.fields) })));
});
app.post('/api/violation-types', (req, res) => {
const { name, category, chapter, description, min_points, max_points, fields, created_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
// Generate a unique type_key from the name, prefixed with 'custom_'
const base = 'custom_' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
let typeKey = base;
let suffix = 2;
while (db.prepare('SELECT id FROM violation_types WHERE type_key = ?').get(typeKey)) {
typeKey = `${base}_${suffix++}`;
}
try {
const result = db.prepare(`
INSERT INTO violation_types (type_key, name, category, chapter, description, min_points, max_points, fields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
typeKey,
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description'])
);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(result.lastInsertRowid);
audit('violation_type_created', 'violation_type', result.lastInsertRowid, created_by || null, { name: row.name, category: row.category });
res.status(201).json({ ...row, fields: JSON.parse(row.fields) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const { name, category, chapter, description, min_points, max_points, fields, updated_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
db.prepare(`
UPDATE violation_types
SET name=?, category=?, chapter=?, description=?, min_points=?, max_points=?, fields=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description']),
id
);
const updated = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
audit('violation_type_updated', 'violation_type', id, updated_by || null, { name: updated.name, category: updated.category });
res.json({ ...updated, fields: JSON.parse(updated.fields) });
});
app.delete('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const usage = db.prepare('SELECT COUNT(*) as count FROM violations WHERE violation_type = ?').get(row.type_key);
if (usage.count > 0) {
return res.status(409).json({ error: `Cannot delete: ${usage.count} violation(s) reference this type. Negate those violations first.` });
}
db.prepare('DELETE FROM violation_types WHERE id = ?').run(id);
audit('violation_type_deleted', 'violation_type', id, null, { name: row.name, type_key: row.type_key });
res.json({ ok: true });
});
// ── PDF endpoint ─────────────────────────────────────────────────────────────
app.get('/api/violations/:id/pdf', async (req, res) => {
try {
const violation = db.prepare(`
SELECT v.*, e.name as employee_name, e.department, e.supervisor
FROM violations v
JOIN employees e ON e.id = v.employee_id
WHERE v.id = ?
`).get(req.params.id);
if (!violation) return res.status(404).json({ error: 'Violation not found' });
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
.get(violation.employee_id) || { active_points: 0, violation_count: 0 };
const scoreForPdf = {
employee_id: violation.employee_id,
active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
violation_count: active.violation_count,
};
const pdfBuffer = await generatePdf(violation, scoreForPdf);
const safeName = violation.employee_name.replace(/[^a-z0-9]/gi, '_');
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`,
'Content-Length': pdfBuffer.length,
});
res.end(pdfBuffer);
} catch (err) {
console.error('[PDF]', err);
res.status(500).json({ error: 'PDF generation failed', detail: err.message });
}
});
// SPA fallback
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}`));
+64
View File
@@ -0,0 +1,64 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
# Runs on the forgerunner host: bundled Docker CLI + mounted /var/run/docker.sock.
runs-on: host
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so `git rev-list --count HEAD` yields the true commit count
# driving both the app version (v1.NNN) and the org.alwisp.version label.
fetch-depth: 0
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
GIT_SHA="$(git rev-parse --short HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
COMMIT_COUNT="$(git rev-list --count HEAD)"
docker build \
--build-arg GIT_SHA="${GIT_SHA}" \
--build-arg BUILD_TIME="${BUILD_TIME}" \
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
--label org.alwisp.git-sha="${{ gitea.sha }}" \
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
--label org.alwisp.repo="${{ gitea.repository }}" \
-t "${IMAGE}:latest" .
docker push "${IMAGE}:latest"
# Dangling-only prune: removes untagged leftovers from previous builds. Never
# removes the tagged :latest or any image referenced by a running container.
- name: Push per-SHA tag
if: success()
run: |
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
GIT_SHA="$(git rev-parse --short=7 HEAD)"
docker tag "${IMAGE}:latest" "${IMAGE}:sha-${GIT_SHA}"
docker push "${IMAGE}:sha-${GIT_SHA}"
- name: Prune dangling images on host
if: always()
run: docker image prune -f 2>/dev/null || true
- name: Trigger PORT redeploy
if: success()
run: |
NAME="${{ gitea.repository }}"; NAME="${NAME##*/}"
curl -fsS -X POST https://port.alwisp.com/hooks/gitea \
-H "X-Deploy-Token: ${{ secrets.WEBHOOK_SECRET }}" \
-H "Content-Type: application/json" \
-d "{\"container\":\"${NAME}\"}" || echo "PORT redeploy trigger failed (non-fatal)"
+18
View File
@@ -0,0 +1,18 @@
.claude/
# The image is built in CI from the Dockerfile and pulled from the registry on
# Unraid. Everything below is either regenerated by that build (see .dockerignore)
# or a local-only artifact, so none of it belongs in git.
# Dependencies — installed fresh during the Docker build
node_modules/
# Client build output — recompiled inside the Docker build (client: vite build)
client/dist/
# Local runtime data (production SQLite DB lives on the /data volume)
data/
# Local image export artifacts (docker save | gzip)
*.tar.gz
*.zip
+4
View File
@@ -0,0 +1,4 @@
{
"giteaActions.baseUrl": "https://git.alwisp.com",
"giteaActions.discovery.mode": "allAccessible"
}
+341
View File
@@ -0,0 +1,341 @@
# AGENTS.md — CPAS Violation Tracker
Developer and AI agent guidance for working on this codebase. Read this before making changes.
---
## Project Purpose
CPAS (Corrective & Progressive Accountability System) is an internal HR tool for documenting employee violations, managing disciplinary tier escalation via a rolling 90-day point system, and producing auditable PDF records. It is a single-container Docker app deployed on a trusted internal network.
**This is a compliance tool.** Data integrity, auditability, and reversibility are first-class concerns. Every architectural decision below exists for a reason.
---
## Stack at a Glance
| Layer | Tech |
|---|---|
| Frontend | React 18 + Vite (SPA, served statically by Express) |
| Backend | Node.js + Express (REST API, `server.js`) |
| Database | SQLite via `better-sqlite3` (synchronous, WAL mode, FK enforcement) |
| PDF | Puppeteer + system Chromium (Alpine-bundled in Docker) |
| Styling | Inline React style objects; `client/src/styles/mobile.css` for breakpoints only |
| Deploy | Docker multi-stage build (Alpine); single container + volume mount at `/data` |
---
## Repository Layout
```
cpas/
├── Dockerfile # Multi-stage: builder (Node+React) → production (Alpine+Chromium)
├── server.js # All API routes + audit helper; single Express entry point
├── auth.js # scrypt hashing, sessions, bootstrap admin, requireAuth/requireAdmin
├── lib/
│ └── rolloff.js # Clean-cycle point roll-off model (computeStanding); SoT for scores
├── db/
│ ├── schema.sql # Base table + view definitions (CREATE TABLE IF NOT EXISTS)
│ └── database.js # DB connection, WAL/FK pragmas, auto-migrations on startup
├── pdf/
│ ├── generator.js # Puppeteer launcher; --no-sandbox for Docker
│ └── template.js # HTML template builder; loads logo from disk
├── demo/ # Static stakeholder demo page served at /demo
│ └── index.html # Synthetic data, no live API calls; registered before SPA catch-all
├── client/
│ ├── vite.config.js
│ ├── src/
│ │ ├── App.jsx # Root component + AppFooter
│ │ ├── main.jsx # React DOM mount
│ │ ├── data/
│ │ │ ├── violations.js # Canonical violation type registry (type key → metadata)
│ │ │ └── departments.js # DEPARTMENTS constant; single source of truth
│ │ ├── hooks/
│ │ │ └── useEmployeeIntelligence.js # Score + history fetch hook
│ │ ├── components/ # One file per component; no barrel index
│ │ └── styles/
│ │ └── mobile.css # Media query overrides only; all other styles are inline
└── README.md # Install (local + Unraid), features, API reference, schema, roadmap
```
---
## Data Model & Compliance Rules
### Tables
| Table | Purpose |
|---|---|
| `employees` | id, name, department, supervisor, notes |
| `violations` | Full incident record; contains immutable scoring fields |
| `violation_resolutions` | Soft-delete records (resolution type, reason, resolver) |
| `violation_amendments` | Field-level diff per amendment (old → new, changed_by, timestamp) |
| `audit_log` | Append-only write action log; never delete from this table |
| `violation_types` | Persisted custom violation type definitions (UI-created; `type_key` prefixed `custom_`) |
| `users` | Auth accounts: username, scrypt `password_hash`, role (`admin`/`user`) |
| `sessions` | Bearer-token login sessions: token, user_id, expires_at (7-day TTL) |
> **Historical note:** an `active_cpas_scores` SQL view once computed a naive per-violation 90-day sum. It has been **dropped** (see `db/database.js`). CPAS standing is now computed in JavaScript by the clean-cycle roll-off model in `lib/rolloff.js` — see the CPAS Tier System section below.
### Immutable Fields (DO NOT allow amendment of these)
The following fields on `violations` are locked after submission. They are the basis for tier calculation and PDF accuracy. **Never expose them to amendment endpoints:**
- `points`
- `violation_type`
- `violation_name`
- `category`
- `incident_date`
- `prior_active_points` (snapshot at insert time)
- `prior_tier_label`
Amendable fields (non-scoring): `incident_time`, `location`, `details`, `submitted_by`, `witness_name`, `acknowledged_by`, `acknowledged_date`, `amount`
### Soft-Delete Pattern
Violations are **never hard-deleted** in normal workflow. Use the `negated` flag + `violation_resolutions` record. Hard delete is reserved for confirmed data-entry errors and requires explicit user confirmation in the UI.
### Prior-Points Snapshot
Every `INSERT` into `violations` must compute and store `prior_active_points` (the employee's current active score before this violation is added). This snapshot ensures PDFs always reflect the accurate historical tier state regardless of subsequent negate/restore actions.
**There are exactly two sanctioned paths that may modify `prior_active_points` after insert; both are audit-logged as `violation_snapshots_recomputed`:**
1. **Back-dated insert (automatic).** If a new violation's `incident_date` precedes existing violations within the 90-day window, those existing violations' snapshots are recomputed via `recomputeSnapshotsAfter()` inside the same transaction as the insert. Logged with `reason: "backdated_insert"`. A back-dated insert is a *timeline rewrite* — the prior violations genuinely had an earlier event in their 90-day window — so their PDFs must reflect that.
2. **Manual backfill (admin-triggered).** `POST /api/employees/:id/recompute-snapshots` calls `recomputeAllSnapshotsForEmployee()` and rewrites every row for that employee from current data. Logged with `reason: "manual_backfill"`. This exists to repair drift from back-dated inserts that happened under older code (before path #1 existed) or any other case where the snapshot diverged from current truth. It is exposed in the UI as the **↻ Backfill Snapshots** button in the Employee Profile Modal next to the Active Violations header. Treat it as a targeted repair tool, not a routine maintenance step.
Negate/restore/amend/hard-delete are NOT timeline rewrites and must never recompute snapshots — PDFs remain stable through those operations by design.
### Audit Log
Every write action (employee created/edited/merged, violation logged/amended/negated/restored/deleted) must call the `audit()` helper in `server.js`. Never skip audit calls on write routes. The audit log is append-only — no UPDATE or DELETE against `audit_log`.
---
## CPAS Tier System
These thresholds are the authoritative values. Any feature touching tiers must use them.
| Points | Tier | Label |
|---|---|---|
| 04 | 0-1 | Elite Standing |
| 59 | 1 | Realignment |
| 1014 | 2 | Administrative Lockdown |
| 1519 | 3 | Verification |
| 2024 | 4 | Risk Mitigation |
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
The canonical tier logic lives in `client/src/components/CpasBadge.jsx` (`TIERS` array, `getTier()`, `getNextTier()`). Do not duplicate this logic elsewhere — import from `CpasBadge`.
### Point roll-off model (clean-cycle)
Active-point math lives in **`lib/rolloff.js`** (`computeStanding()`), not in SQL. The rule is a **clean-cycle roll-off**, NOT a per-violation 90-day window:
- Points retire only after the employee goes a full **90 consecutive days with no new non-negated violation**.
- **Any new violation resets that 90-day clock for the employee's entire balance.**
- Each completed clean cycle removes **5 points, oldest violation first**; each further roll-off requires another fresh 90-day clean stretch.
This is deliberately not "each violation expires 90 days after its own incident date" — one new violation pushes roll-off back for everything. Because the model is order-dependent, it is computed in JS and is the single source of truth for point totals. Do not reintroduce a SQL view or sum points client-side.
---
## Violation Type Registry
All violation types are defined in `client/src/data/violations.js` as `violationData`. Each entry includes:
```js
{
name: string, // Display name
category: string, // Grouping for UI display
minPoints: number, // Slider minimum
maxPoints: number, // Slider maximum (min === max means fixed, no slider)
chapter: string, // Policy chapter reference
fields: string[], // Which context fields to show ('time', 'minutes', 'amount', 'location', 'description')
description: string, // Plain-language definition shown in UI
}
```
To add a new violation type: add an entry to `violationData` with a unique camelCase key. Do not add new categories without confirming with the project owner — categories appear in UI groupings.
---
## Authentication
All auth logic lives in **`auth.js`** (backend) and **`client/src/auth.js`** (token storage + axios interceptors). It is intentionally lightweight for a trusted-LAN deployment.
- **Password hashing**: scrypt, no external dependency. Stored format `scrypt$<saltHex>$<hashHex>`; verified with `crypto.timingSafeEqual`.
- **Sessions**: opaque 32-byte bearer tokens in the `sessions` table, **7-day TTL**. The client stores the token in `localStorage` (`cpas_token`) and an axios request interceptor attaches `Authorization: Bearer <token>`; a response interceptor clears the token and bounces to the login screen on any `401`.
- **Route guard**: `app.use('/api', auth.requireAuth)` protects every `/api/*` route registered after it. The only public routes are `GET /api/health` and `POST /api/auth/login`, registered *before* the guard. Admin-only routes add `auth.requireAdmin`.
- **Bootstrap admin**: created/re-synced every startup from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars. Its password is owned by the environment — it cannot be changed from the UI (a UI change is overwritten on next restart). Rotate by changing `ADMIN_PASSWORD` and restarting. The Docker image ships a placeholder `changeme` that **must** be overridden at deploy time.
- **UI-managed users**: admins create/delete users and reset passwords via `/api/users*` (min 6-char passwords, `admin`/`user` roles). These accounts are unaffected by restarts.
- **Audit**: `login_success`, `login_failed`, `user_created`, `user_password_reset`, and `user_deleted` are all written to `audit_log`.
Do not weaken or bypass the global guard, and do not persist tokens anywhere but the existing `localStorage` key.
---
## Coding Standards
### Backend (`server.js`)
- Use `better-sqlite3` synchronous API. No async DB calls. This is intentional — it simplifies route handlers and matches Express's sync error handling.
- All prepared statements use positional `?` parameters. Never interpolate user input into SQL strings.
- Every POST/PUT/PATCH/DELETE route must:
1. Validate required inputs and return `400` with a descriptive `{ error: '...' }` body on failure.
2. Call `audit()` on success.
3. Return `{ error: '...' }` (not HTML) on all error paths.
- Group routes by resource (Employees, Violations, Dashboard, Audit). Match the existing comment banner style: `// ── Resource Name ───`.
- **Auth is global, not per-route.** `app.use('/api', auth.requireAuth)` guards every `/api/*` route registered after it in `server.js`. The only public routes are `GET /api/health` and `POST /api/auth/login`, both registered *before* the guard. Admin-only routes add `auth.requireAdmin` as a second middleware. When adding a route, register it after the guard and let it inherit auth — do not add ad-hoc token checks. See the Authentication section below.
### Frontend (React)
- **Styling**: Use inline style objects defined as a `const s = { ... }` block at the top of each component file. Do not add CSS classes or CSS modules — except for responsive breakpoints which go in `mobile.css`.
- **Data constants**: Import violation types from `../data/violations`, departments from `../data/departments`, tier logic from `./CpasBadge`. Do not hardcode these values in components.
- **Toasts**: Use `useToast()` from `ToastProvider` for all user-facing feedback. Do not use `alert()` or `console.log` for user messages.
- **HTTP**: Use `axios` (already imported in form/modal components). Do not introduce `fetch` unless there is a compelling reason — keep it consistent.
- **State**: Prefer local `useState` over lifting state unless data is needed by multiple unrelated components. The only global context is `ToastProvider`.
- **Component files**: One component per file. Name the file to match the export. No barrel `index.js` files.
### Mobile / responsive
The UI targets phones **375px and up** with no external CSS framework. Durable rules:
- **Breakpoints**: `768px` (phones → card layouts, stacked nav), `480px` (small phones → single-column stats), `375px` (minimum supported width). Tablet tweaks at `1024px`. Put breakpoint rules in `mobile.css`, not inline styles.
- **Layout switch**: at ≤768px the dashboard table swaps to a card layout — desktop renders `<table>` in `Dashboard.jsx`; mobile renders `DashboardMobile.jsx`. Gate with the `isMobile` media-query pattern.
- **Touch & iOS**: tap targets ≥ **44px**; form inputs use **16px** font to stop iOS focus-zoom; use `-webkit-overflow-scrolling: touch` for momentum scroll.
- **`useMediaQuery` hook**: currently defined inline in both `App.jsx` and `Dashboard.jsx` (see TODO #8 to extract to `src/hooks/useMediaQuery.js`). Reuse the extracted hook once it lands rather than copying it a third time.
- Test on a real device — Chrome DevTools device mode does not exercise touch behavior.
### Database Migrations
New columns are added via the auto-migration pattern in `database.js`. Do not modify `schema.sql` for columns that already exist in production. Instead:
```js
// Example: adding a new column to violations
const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name);
if (!cols.includes('new_column')) db.exec("ALTER TABLE violations ADD COLUMN new_column TEXT");
```
Add a comment describing the feature the column enables. `schema.sql` is only for base tables — use it only for brand-new tables.
---
## Schema Changes: Decision Checklist
Before adding a column or table, answer:
1. **Does it affect scoring?** If yes, it must be immutable after insert and included in `prior_active_points` computation logic.
2. **Does it need audit trail?** If it tracks a change to an existing record, add a corresponding entry pattern to `violation_amendments` or `audit_log`.
3. **Is it soft-deletable?** Prefer `negated`/flag patterns over hard deletes for anything HR might need to reverse.
4. **Does it appear on PDFs?** Update `pdf/template.js` to reflect it. Test PDF output after schema changes.
5. **Does it affect point calculations?** If so, update `computeStanding()` in `lib/rolloff.js` — that function is the single source of truth for active-point totals. (There is no longer a SQL scoring view.)
---
## PDF Generation
- PDFs are generated on-demand via `GET /api/violations/:id/pdf`. No pre-caching.
- Template is built in `pdf/template.js`. It receives the full violation + employee record. Logo is loaded from disk at startup and embedded as base64.
- Puppeteer launches with `--no-sandbox --disable-setuid-sandbox` (required for Docker; safe in this deployment context).
- Acknowledgment rendering: if `acknowledged_by` is set, show name + date in signature block. If not, render blank wet-ink signature lines.
- After any schema change that adds user-visible fields, update the template to include the new field where appropriate.
---
## Development Workflow
### Local Development (without Docker)
```bash
# Terminal 1 — backend
npm install
node server.js # Serves API on :3001 and client/dist statically
# Terminal 2 — frontend (hot reload)
cd client
npm install
npm run dev # Vite dev server on :5173 (proxy to :3001 configured in vite.config.js)
```
### Build & Deploy
```bash
# Build Docker image (compiles React inside container)
docker build -t cpas .
# Run (local)
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
# Unraid: build → save → transfer → load → run with --pids-limit 2048
# See the "Deploying on Unraid" section of README.md for full instructions
```
**Unraid PID limit is critical.** Chromium spawns many child processes for PDF generation. Always include `--pids-limit 2048` on Unraid containers or PDF generation will fail silently.
### Health Check
`GET /api/health` returns `{ status: 'ok', timestamp, version }`. The `version` field is populated by the Dockerfile at build time from git commit SHA. In local dev it returns `{ sha: 'dev' }` — this is expected.
---
## Forward-Thinking Development Guidelines
### Adding New Features
- **Score-affecting logic belongs in `lib/rolloff.js`**, not scattered across route handlers or duplicated in SQL. `computeStanding()` is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add it as an option/variant of the roll-off model — don't sum points ad hoc in a route.
- **New violation fields**: Add to `schema.sql` for fresh installs AND to the migration block in `database.js` for existing databases. Both are required.
- **Reporting features**: Future aggregate queries should call `computeStanding()` (or join against `audit_log`) rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace, registered after the auth guard.
- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or call `computeStanding()` — do not add side effects directly into violation insert routes.
- **Auth is already implemented** (see the Authentication section). It is global middleware (`app.use('/api', auth.requireAuth)`) applied before all `/api` routes, exactly as this guidance recommends — do not add per-route auth checks. When wiring identity into records, pass the signed-in user into `performed_by`/`changed_by` fields on audit and amendment records.
- **Multi-tenant / multi-site**: The schema is single-tenant. If site isolation is ever needed, add a `site_id` foreign key to `employees` and `violations` as a migration column, then scope all queries with a `WHERE site_id = ?` clause.
### What NOT to Do
- Do not compute active CPAS scores by summing violation points client-side. Always fetch the server-computed standing (`/api/employees/:id/score`, `/api/dashboard`, etc.), which runs the clean-cycle model in `lib/rolloff.js`. A naive client-side sum ignores roll-off and will be wrong.
- Do not modify `prior_active_points` after a violation is inserted, EXCEPT via one of the two sanctioned paths: automatic recompute on a back-dated insert (`recomputeSnapshotsAfter()`), or manual admin backfill (`recomputeAllSnapshotsForEmployee()` behind `POST /api/employees/:id/recompute-snapshots` and the **↻ Backfill Snapshots** UI button). Both are audit-logged as `violation_snapshots_recomputed`. Never recompute snapshots on negate, restore, amend, or hard delete.
- Do not add columns to `audit_log`. It is append-only with a fixed schema.
- Do not add a framework or ORM. Raw SQL with prepared statements is intentional — it keeps the query behavior explicit and the dependency surface small.
- Do not add a build step beyond `vite build`. The backend is plain CommonJS `require()`; do not transpile it.
- Do not use `alert()`, `console.log` for user messages, or `document.querySelector` inside React components.
---
## Documentation Standards
### Code Comments
- Comment **why**, not **what**. If the reason for a decision is not obvious from the code, explain it.
- Use the existing banner style for section groupings in `server.js`:
```js
// ── Section Name ─────────────────────────────────────────────────────────────
```
- Mark non-obvious schema columns with inline SQL comments (see `schema.sql` for examples).
- When adding a migration block, include a comment naming the feature it enables.
### In-App Documentation
The `ReadmeModal.jsx` component renders an admin reference panel accessible via the `? Docs` button. When adding a significant new feature:
- Add it to the feature map section of the docs modal.
- Update the tier system table if thresholds change.
- Move completed roadmap items from the "Proposed" section to the "Completed" section.
### README
Update `README.md` when:
- A new environment variable is introduced.
- The Docker run command changes (new volume, port, or flag).
- A new top-level feature is added that HR administrators need to know about.
Do not add implementation details to README — that belongs in code comments or AGENTS.md.
---
## Constraints & Non-Goals
- **Authentication is intentionally minimal.** A login gate, scrypt-hashed passwords, bearer-token sessions, and an admin/user role split exist (see the Authentication section) — but the app still runs on a trusted LAN. Do not layer on OAuth, SSO, password-reset email flows, or finer-grained permission tiers without explicit direction from the project owner. There is no CSRF/rate-limiting hardening by design; keep it deployed behind the LAN.
- **No external dependencies beyond what's in `package.json`.** Avoid introducing new npm packages unless they solve a clearly scoped problem. Prefer using existing stack capabilities.
- **No client-side routing library.** Navigation between Violation Form, Dashboard, and modals is handled via `App.jsx` state (`view` prop). Do not introduce React Router unless the navigation model meaningfully grows beyond 34 views.
- **No test suite currently.** If adding tests, use Vitest for frontend and a lightweight assertion library for backend routes. Do not add a full testing framework without discussion.
- **SQLite only.** Do not introduce Postgres, Redis, or other datastores. The single-file DB on a Docker volume is the correct solution for this scale.
+10 -1
View File
@@ -23,17 +23,26 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
ENV PORT=3001
ENV DB_PATH=/data/cpas.db
# ── Bootstrap admin credentials ───────────────────────────────────────────────
# The admin account is created/synced from these on startup. OVERRIDE the
# password at deploy time, e.g.:
# docker run -e ADMIN_PASSWORD='your-strong-secret' ...
# Rotating ADMIN_PASSWORD here and restarting rotates the live admin credential.
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=changeme
WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/client/dist ./client/dist
COPY server.js ./
COPY auth.js ./
COPY package.json ./
COPY db/ ./db/
COPY pdf/ ./pdf/
COPY lib/ ./lib/
COPY demo/ ./demo/
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
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
CMD ["node", "server.js"]
+152 -24
View File
@@ -19,22 +19,26 @@ Everything else — Node.js, npm, React build, Chromium for PDF — happens insi
# 1. Build the image (installs all deps + compiles React inside Docker)
docker build -t cpas .
# 2. Run it
# 2. Run it — set a strong admin password (the app requires login)
docker run -d --name cpas \
-p 3001:3001 \
-v cpas-data:/data \
-e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD='choose-a-strong-secret' \
cpas
# 3. Open
# http://localhost:3001
# 3. Open http://localhost:3001 and sign in as admin
```
> **The app requires login.** The admin account is created from `ADMIN_USERNAME` / `ADMIN_PASSWORD` on every start. The image ships a placeholder password (`changeme`) that **must** be overridden. To rotate the admin password later, change `ADMIN_PASSWORD` and restart. Additional users are created in-app by an admin (**Users** button, top-right).
## Update After Code Changes
```bash
docker build -t cpas .
docker stop cpas && docker rm cpas
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas
docker run -d --name cpas -p 3001:3001 -v cpas-data:/data \
-e ADMIN_PASSWORD='choose-a-strong-secret' cpas
```
---
@@ -85,12 +89,16 @@ docker run \
-e HOST_CONTAINERNAME="cpas" \
-e 'PORT'='3001' \
-e 'DB_PATH'='/data/cpas.db' \
-e 'ADMIN_USERNAME'='admin' \
-e 'ADMIN_PASSWORD'='choose-a-strong-secret' \
-l net.unraid.docker.managed=dockerman \
-l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \
-v '/mnt/user/appdata/cpas/db':'/data':'rw' \
cpas:latest
```
> **Set a strong `ADMIN_PASSWORD`.** The app requires login and the admin account is created/re-synced from these env vars on every start. The image ships a placeholder (`changeme`) that must be overridden. To rotate the admin password later, edit this variable and **Restart** the container. Additional users are created in-app by an admin (**Users** button).
Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
### Key settings explained
@@ -102,6 +110,8 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures |
| `PORT` | `3001` | Express listen port inside the container |
| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container |
| `ADMIN_USERNAME` | `admin` | Bootstrap admin username (created/synced on startup) |
| `ADMIN_PASSWORD` | *(strong secret)* | Bootstrap admin password — **must** override the `changeme` default; re-syncs on every start |
| Volume | `/mnt/user/appdata/cpas/db``/data` | Persists the database across container restarts and rebuilds |
### Updating on Unraid
@@ -113,24 +123,67 @@ Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned).
> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container.
### Unraid GUI alternative (Add Container form)
If you prefer the Unraid GUI over the CLI, load the image (`docker load < cpas-latest.tar.gz`), then **Docker → Add Container** (Advanced View on) and fill in the equivalent settings:
| Field | Value |
|---|---|
| Name | `cpas` |
| Repository | `cpas:latest` (local image; leave Docker Hub URL blank) |
| Network Type | `Bridge` (or a custom `br0` with a static IP) |
| WebUI | `http://[IP]:[PORT:3001]` |
| Port | Container `3001` → Host `3001` (TCP) |
| Path | Container `/data` → Host `/mnt/user/appdata/cpas/db` (Read/Write) |
| Variable | `PORT` = `3001` |
| Variable | `DB_PATH` = `/data/cpas.db` |
| Variable | `ADMIN_USERNAME` = `admin` |
| Variable | `ADMIN_PASSWORD` = *(a strong secret — override the default)* |
| Extra Parameters | `--pids-limit 2048` (Advanced View → *Extra Parameters*) |
### Verify
1. Docker tab → **cpas** shows a green icon → click it → **WebUI** (or open `http://[IP]:3001`).
2. A **login screen** appears — sign in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` you set.
3. Confirm **● API connected** in the header.
4. Health check (no login required): `http://[IP]:3001/api/health``{"status":"ok", ...}`.
### Troubleshooting
| Problem | Fix |
|---|---|
| Container won't start | Docker tab → container icon → **Logs** |
| Can't log in / forgot admin password | Edit Container → set a new `ADMIN_PASSWORD`**Restart**. It re-syncs from this variable on every start. |
| `ADMIN_PASSWORD not set` in logs | Add the `ADMIN_PASSWORD` variable and restart — without it the admin account is not created. |
| PDF generation fails / container crashes under load | Confirm `--pids-limit 2048` is set — Chromium spawns many processes per PDF. |
| Port 3001 conflict | Change the 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 sh`, then `sqlite3 /data/cpas.db ".tables"` |
---
## Stakeholder Demo
A standalone demo page with synthetic data is available at `/demo` (e.g. `http://localhost:3001/demo`).
It is served as a static route before the SPA catch-all and requires no authentication.
Useful for showing the app to stakeholders without exposing live employee data.
It is a static page (its own hardcoded data, no live API calls), served before the SPA catch-all, so it stays reachable without logging in. The live app and all `/api/*` data routes still require authentication — only this synthetic page is public. Useful for showing the app to stakeholders without exposing live employee data.
---
## Features
### Authentication & User Accounts
- **Login gate** on the entire app — every page and every `/api/*` route (except `/api/health` and the login endpoint) requires a valid session
- **Bearer-token sessions** with a 7-day lifetime; token stored client-side and attached to every request, cleared automatically on expiry
- **Bootstrap admin** created/synced on startup from the `ADMIN_USERNAME` / `ADMIN_PASSWORD` environment variables — rotate the password by changing the env var and restarting
- **Admin-managed users** — admins add/delete accounts and reset passwords from the in-app **Users** panel; two roles (`admin`, `user`)
- Passwords stored as scrypt hashes (no external auth dependency); logins and account changes are audit-logged
### Company Dashboard
- Live table of all employees sorted by active CPAS points (highest risk first)
- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score
- **At-risk badge**: flags employees within 2 points of the next tier escalation
- Search/filter by name, department, or supervisor
- **Department filter**: pre-loaded dropdown of all departments for quick scoped views
- Click any employee name to open their full profile modal
- **📋 Audit Log** button — filterable, paginated view of all system write actions
@@ -138,9 +191,11 @@ Useful for showing the app to stakeholders without exposing live employee data.
- Select existing employee or enter new employee by name
- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting
- Violation type dropdown grouped by category; shows prior 90-day counts inline
- **Custom violation types**: add or edit user-defined types directly from the form (`+ Add Type` / `Edit Type` buttons); persisted to the database and merged into the dropdown alongside hardcoded types
- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy
- Repeat offense badge with prior count displayed
- Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type
- **Financial amount tracking**: dollar amount in question recorded for chargeback / receipt / custom financial violations; surfaces on the PDF for repayment records and is audit-logged on edit
- **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission
- Point slider for discretionary adjustments within the violation's min/max range
- **Employee Acknowledgment section**: optional "received by employee" name and date fields; when filled, the PDF signature block shows the recorded acknowledgment instead of a blank signature line
@@ -156,7 +211,8 @@ Useful for showing the app to stakeholders without exposing live employee data.
- Hard delete option for data entry errors
- PDF download for any historical violation record
- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring
- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators
- **Point Roll-Off Timeline** — lists each upcoming roll-off event (5 points retire per completed 90-day clean cycle, oldest first), with a progress bar, days-remaining countdown anchored to the last violation date, projected post-roll-off score, and tier-drop indicators
- **↻ Backfill Snapshots** button (next to the Active Violations header) — manually rebuilds the `prior_active_points` snapshot on every violation for this employee. Use after back-dating a violation under older code, or any time a regenerated PDF shows stale prior-point totals. Audit-logged as `violation_snapshots_recomputed` with reason `manual_backfill`. See [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots) below.
- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit
### Audit Log
@@ -198,7 +254,7 @@ Useful for showing the app to stakeholders without exposing live employee data.
| 2529 | 5 | Final Decision |
| 30+ | 6 | Separation |
Scores are computed over a **rolling 90-day window** (negated violations excluded).
Scores follow a **clean-cycle roll-off** (negated violations excluded): points retire only after the employee goes a full **90 consecutive days with no new violation**, and any new violation resets that clock for their entire balance. Each completed clean cycle drops **5 points, oldest first**. This is deliberately *not* a per-violation "expires 90 days after its own date" window — the math lives in [`lib/rolloff.js`](lib/rolloff.js) (`computeStanding`).
### PDF Generation
- Puppeteer + system Chromium (bundled in Docker image)
@@ -207,30 +263,86 @@ Scores are computed over a **rolling 90-day window** (negated violations exclude
- Filename: `CPAS_<EmployeeName>_<IncidentDate>.pdf`
- PDF captures prior active points **at the time of the incident** (snapshot stored on insert)
- **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing
- **Back-dated inserts** auto-refresh the snapshot on downstream violations whose 90-day prior window now includes the new earlier event (handled inside the insert transaction by `recomputeSnapshotsAfter()`). If a back-date happened under older code that lacked this auto-refresh, use the **↻ Backfill Snapshots** button in the Employee Profile Modal — see [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots).
---
## Backfilling Prior-Points Snapshots
Each violation stores a `prior_active_points` snapshot at insert time so its PDF always reflects the score *as it was on the incident date* (and stays stable through later negate/restore actions). Normally you never touch this column.
There is one situation where the snapshot can drift from current truth: a violation was back-dated *before* `recomputeSnapshotsAfter()` shipped (commit `e2c352d`), so the auto-refresh never ran on the violations that now sit inside its 90-day window. Symptom: re-downloading the PDF for the newer violation shows "Prior Active Points: 0" even though an earlier active violation clearly exists in the timeline.
**To fix:**
1. Open the affected employee's profile modal.
2. Click **↻ Backfill Snapshots** next to the **Active Violations** header.
3. Confirm the prompt. A toast reports `Updated X of Y snapshot(s)` or `Snapshots already up to date`.
4. Re-download the PDFs — they now reflect the corrected prior totals.
**What it does, exactly:**
- Iterates every violation belonging to that employee (active *and* negated).
- Recomputes each row's `prior_active_points` using the current set of non-negated violations in the 90 days before its `incident_date`.
- Writes only the rows that actually changed and reports the diff.
- Runs inside a single transaction.
- Writes one `violation_snapshots_recomputed` entry to the audit log with `reason: "manual_backfill"` and the per-row before/after values.
**When *not* to use it:**
- After a negate, restore, amend, or hard delete in normal workflow. The auto-managed snapshot is correct in those cases by design (PDFs are intentionally stable through negate/restore).
- As a routine maintenance step. It's a targeted repair tool, not a recurring task. If you find yourself reaching for it after normal back-dated inserts, file a bug — the auto-recompute should already be handling those.
**API endpoint:** `POST /api/employees/:id/recompute-snapshots`
Response shape:
```json
{ "success": true, "scanned": 2, "updated": 1, "changes": [
{ "id": 47, "incident_date": "2026-04-02", "old": 0, "new": 3 }
]}
```
---
## API Reference
All routes except `GET /api/health` and `POST /api/auth/login` require a valid `Authorization: Bearer <token>` header. Routes marked **admin** additionally require an admin role.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api/health` | Health check (public; returns build SHA + timestamp) |
| POST | `/api/auth/login` | Public — exchange username + password for a session token |
| GET | `/api/auth/me` | Current session's user |
| POST | `/api/auth/logout` | Invalidate the current session token |
| GET | `/api/users` | **admin** — list user accounts |
| POST | `/api/users` | **admin** — create a user (username, password ≥6 chars, role) |
| PATCH | `/api/users/:id/password` | **admin** — reset a user's password |
| DELETE | `/api/users/:id` | **admin** — delete a user (cannot delete self) |
| GET | `/api/employees` | List all employees (includes `notes`) |
| GET | `/api/employees/:id` | Single employee record |
| POST | `/api/employees` | Create or upsert employee |
| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes |
| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) |
| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations |
| POST | `/api/employees/:id/recompute-snapshots` | Manual backfill — rebuild `prior_active_points` on every violation for this employee. See [Backfilling Prior-Points Snapshots](#backfilling-prior-points-snapshots) |
| GET | `/api/employees/:id/score` | Get active CPAS score for employee |
| GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining |
| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) |
| GET | `/api/employees/:id/violation-counts` | 90-day non-negated counts grouped by violation type |
| GET | `/api/employees/:id/violation-counts/alltime` | All-time non-negated counts + max points used per type |
| GET | `/api/dashboard` | All employees with active points + violation counts |
| POST | `/api/violations` | Log a new violation (accepts `acknowledged_by`, `acknowledged_date`) |
| POST | `/api/violations` | Log a new violation (accepts `acknowledged_by`, `acknowledged_date`, `amount`) |
| GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts |
| PATCH | `/api/violations/:id/negated` | Negate a violation (soft delete + resolution record) |
| PATCH | `/api/violations/:id/negate` | Negate a violation (soft delete + resolution record) |
| PATCH | `/api/violations/:id/restore` | Restore a negated violation |
| PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging |
| GET | `/api/violations/:id/amendments` | Get amendment history for a violation |
| DELETE | `/api/violations/:id` | Hard delete a violation |
| GET | `/api/violations/:id/pdf` | Download violation PDF |
| GET | `/api/violation-types` | List custom violation types |
| POST | `/api/violation-types` | Create a custom violation type |
| PUT | `/api/violation-types/:id` | Update a custom violation type |
| DELETE | `/api/violation-types/:id` | Delete a custom violation type (blocked if any violation references it) |
| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) |
---
@@ -242,9 +354,12 @@ cpas/
├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium
├── .dockerignore
├── package.json # Backend (Express) deps
├── server.js # API + static file server
├── server.js # API + static file server + global auth guard
├── auth.js # scrypt hashing, sessions, bootstrap admin, auth middleware
├── lib/
│ └── rolloff.js # Clean-cycle point roll-off model (computeStanding)
├── db/
│ ├── schema.sql # Tables + 90-day active score view
│ ├── schema.sql # Base table definitions (no views)
│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations
├── pdf/
│ ├── generator.js # Puppeteer PDF generation
@@ -258,14 +373,19 @@ cpas/
├── main.jsx
├── App.jsx # Root app + AppFooter (copyright, dev ticker, Gitea link)
├── data/
── violations.js # All CPAS violation definitions + groups
── violations.js # All hardcoded CPAS violation definitions + groups
│ └── departments.js # DEPARTMENTS constant; single source of truth
├── hooks/
│ └── useEmployeeIntelligence.js # Score + history hook
├── styles/
│ └── mobile.css # Mobile breakpoint overrides only
└── components/
├── CpasBadge.jsx # Tier badge + color logic
├── CpasBadge.jsx # Tier badge + color logic (canonical TIERS, getTier)
├── TierWarning.jsx # Pre-submit tier crossing alert
├── Dashboard.jsx # Company-wide leaderboard + audit log trigger
├── ViolationForm.jsx # Violation entry form + ack signature fields
├── DashboardMobile.jsx # Mobile-optimized dashboard layout
├── ViolationForm.jsx # Violation entry form + ack signature + amount field
├── ViolationTypeModal.jsx # Create / edit / delete custom violation types
├── EmployeeModal.jsx # Employee profile + history modal
├── EditEmployeeModal.jsx # Employee edit + merge duplicate
├── AmendViolationModal.jsx # Non-scoring field amendment + diff history
@@ -282,14 +402,18 @@ cpas/
## Database Schema
Six tables + one view:
Eight tables, no views. Active-point totals are computed in JavaScript by [`lib/rolloff.js`](lib/rolloff.js), not in SQL:
- **`employees`** — id, name, department, supervisor, **notes**
- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging, `acknowledged_by` and `acknowledged_date` for employee acknowledgment
- **`employees`** — id, name, department, supervisor, notes
- **`violations`** — full incident record including `prior_active_points` snapshot, `acknowledged_by` / `acknowledged_date`, and `amount` (financial amount in question for chargeback/repayment)
- **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations)
- **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment
- **`violation_types`** — persisted custom violation type definitions added via the UI; `type_key` is prefixed `custom_` to prevent collisions with hardcoded keys
- **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp)
- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee
- **`users`** — auth accounts: username, scrypt `password_hash`, role (`admin`/`user`)
- **`sessions`** — bearer-token login sessions: token, user_id, expires_at (7-day TTL)
> An `active_cpas_scores` SQL view existed in earlier versions; it was dropped once scoring moved to the order-dependent clean-cycle model in `lib/rolloff.js`.
---
@@ -306,6 +430,7 @@ Point values, violation type, and incident date are **immutable** after submissi
| `witness_name` | Witness on record |
| `acknowledged_by` | Employee who acknowledged receipt |
| `acknowledged_date` | Date of employee acknowledgment |
| `amount` | Dollar amount in question (financial violations); typo-correctable for repayment records |
---
@@ -339,6 +464,10 @@ Point values, violation type, and incident date are **immutable** after submissi
| 7 | Department dropdown | Pre-loaded select on the violation form replacing free-text department input; shared `DEPARTMENTS` constant |
| 8 | Stakeholder demo page | Standalone `/demo` route with synthetic data; static HTML served before SPA catch-all; useful for non-live presentations |
| 8 | App footer | Copyright (© Jason Stedwell), live dev ticker since first commit, Gitea repo icon+link |
| 9 | Custom violation types | Persisted user-defined violation types created from the form; `+ Add Type` / `Edit Type` UI; merged into the dropdown alongside hardcoded types; delete blocked when in use |
| 9 | Financial amount tracking | `amount` field on financial violations (chargeback, receipt negligence, custom types with the field enabled); stored on `violations`, rendered prominently on the PDF, amendable with audit-logged diffs |
| 10 | Authentication & user accounts | Login gate on all routes; scrypt-hashed passwords; 7-day bearer sessions; env-bootstrapped admin; admin-managed users with `admin`/`user` roles |
| 10 | Build version badge | Git SHA + build timestamp injected into `version.json` at `docker build`; surfaced in the footer and `/api/health` |
---
@@ -382,13 +511,12 @@ Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High
|---------|--------|-------------|
| Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed |
| At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var |
| version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` |
#### Infrastructure & Ops
| Feature | Effort | Description |
|---------|--------|-------------|
| Multi-user auth | 🔴 | Simple login with role-based access (admin, supervisor, read-only); currently the app runs on a trusted internal network with no auth |
| Expanded roles | 🟡 | Finer-grained access beyond the current `admin`/`user` split (e.g. supervisor-scoped or read-only roles) — builds on the shipped auth foundation |
| Automated DB backup | 🟡 | Cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule |
| Dark/light theme toggle | 🟡 | The UI is currently dark-only; a toggle would improve usability in bright environments |
+157
View File
@@ -0,0 +1,157 @@
const crypto = require('crypto');
const db = require('./db/database');
// Sessions live for 7 days, after which the user must log in again.
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7;
// ── Password hashing (scrypt, no external deps) ───────────────────────────────
// Stored format: scrypt$<saltHex>$<hashHex>
function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, 64);
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
}
function verifyPassword(password, stored) {
try {
const [scheme, saltHex, hashHex] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = crypto.scryptSync(password, salt, expected.length);
return crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
// ── Bootstrap admin from environment ──────────────────────────────────────────
// Creates the admin account if missing, or re-syncs its password every startup
// so rotating ADMIN_PASSWORD in the container rotates the live credential.
// Because of this, the bootstrap admin's password cannot be changed from the UI
// — it is owned by the Docker environment. Additional users created in the UI
// are unaffected.
function bootstrapAdmin() {
const username = (process.env.ADMIN_USERNAME || 'admin').trim();
const password = process.env.ADMIN_PASSWORD;
if (!password) {
console.warn('[AUTH] ADMIN_PASSWORD is not set — admin account was NOT bootstrapped. Set it in the container environment.');
return;
}
const existing = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!existing) {
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), 'admin');
console.log(`[AUTH] Bootstrapped admin user '${username}'.`);
} else {
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE id = ?')
.run(hashPassword(password), 'admin', existing.id);
console.log(`[AUTH] Synced admin user '${username}' password from environment.`);
}
}
// ── User management ───────────────────────────────────────────────────────────
function findUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim());
}
function listUsers() {
return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY username ASC').all();
}
function createUser({ username, password, role }) {
const uname = String(username || '').trim();
if (!uname) throw Object.assign(new Error('username is required'), { status: 400 });
if (!password) throw Object.assign(new Error('password is required'), { status: 400 });
if (password.length < 6) throw Object.assign(new Error('password must be at least 6 characters'), { status: 400 });
const safeRole = role === 'admin' ? 'admin' : 'user';
if (findUserByUsername(uname)) {
throw Object.assign(new Error('A user with that username already exists'), { status: 409 });
}
const result = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(uname, hashPassword(password), safeRole);
return { id: result.lastInsertRowid, username: uname, role: safeRole };
}
function deleteUser(id) {
return db.prepare('DELETE FROM users WHERE id = ?').run(id).changes > 0;
}
function setPassword(id, password) {
if (!password || password.length < 6) {
throw Object.assign(new Error('password must be at least 6 characters'), { status: 400 });
}
return db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id).changes > 0;
}
// ── Sessions ──────────────────────────────────────────────────────────────────
function createSession(user) {
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + SESSION_TTL_MS).toISOString();
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, user.id, expires);
return token;
}
function getSessionUser(token) {
if (!token) return null;
const row = db.prepare(`
SELECT s.expires_at, u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?
`).get(token);
if (!row) return null;
if (new Date(row.expires_at).getTime() < Date.now()) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null;
}
return { id: row.id, username: row.username, role: row.role };
}
function destroySession(token) {
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
function login(username, password) {
const user = findUserByUsername(username);
if (!user || !verifyPassword(password, user.password_hash)) return null;
const token = createSession(user);
return { token, user: { id: user.id, username: user.username, role: user.role } };
}
// ── Express middleware ────────────────────────────────────────────────────────
function tokenFromReq(req) {
const h = req.headers.authorization || '';
return h.startsWith('Bearer ') ? h.slice(7) : null;
}
function requireAuth(req, res, next) {
const user = getSessionUser(tokenFromReq(req));
if (!user) return res.status(401).json({ error: 'Authentication required' });
req.user = user;
req.authToken = tokenFromReq(req);
next();
}
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
module.exports = {
bootstrapAdmin,
login,
destroySession,
listUsers,
createUser,
deleteUser,
setPassword,
requireAuth,
requireAdmin,
};
+1987
View File
File diff suppressed because it is too large Load Diff
+71 -2
View File
@@ -1,11 +1,17 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import ViolationForm from './components/ViolationForm';
import Dashboard from './components/Dashboard';
import ReadmeModal from './components/ReadmeModal';
import LoginModal from './components/LoginModal';
import UserManagementModal from './components/UserManagementModal';
import ToastProvider from './components/ToastProvider';
import { getToken, clearToken, setUnauthorizedHandler } from './auth';
import './styles/mobile.css';
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
// TODO [CLEANUP #18]: DevTicker is a dev vanity widget that ships to prod.
// Either gate with `import.meta.env.DEV` or remove from the footer.
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
function elapsed(from) {
@@ -101,7 +107,9 @@ const tabs = [
{ id: 'violation', label: '+ New Violation' },
];
// Responsive utility hook
// TODO [MAJOR #8]: Move to src/hooks/useMediaQuery.js — this hook is duplicated
// verbatim in Dashboard.jsx. Also remove `matches` from the useEffect dep array
// (it changes inside the effect, which can cause a loop on strict-mode mount).
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
@@ -142,6 +150,28 @@ const s = {
alignItems: 'center',
gap: '6px',
},
navBtn: {
background: 'none',
border: '1px solid #2a2b3a',
color: '#9ca0b8',
borderRadius: '6px',
padding: '6px 14px',
fontSize: '12px',
cursor: 'pointer',
fontWeight: 600,
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '6px',
},
userBadge: {
fontSize: '12px',
color: '#c0c2d6',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
},
main: { flex: 1 },
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
};
@@ -225,7 +255,10 @@ const sf = {
export default function App() {
const [tab, setTab] = useState('dashboard');
const [showReadme, setShowReadme] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [version, setVersion] = useState(null);
const [user, setUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => {
@@ -235,8 +268,29 @@ export default function App() {
.catch(() => {});
}, []);
// Validate any stored session on load; fall back to the login screen on 401.
useEffect(() => {
setUnauthorizedHandler(() => setUser(null));
if (!getToken()) { setAuthChecked(true); return; }
axios.get('/api/auth/me')
.then(r => setUser(r.data.user))
.catch(() => clearToken())
.finally(() => setAuthChecked(true));
}, []);
const handleLogout = async () => {
try { await axios.post('/api/auth/logout'); } catch { /* token already gone */ }
clearToken();
setUser(null);
};
if (!authChecked) return null;
if (!user) return <LoginModal onSuccess={setUser} />;
return (
<ToastProvider>
{/* TODO [MAJOR #9]: Inline <style> tags re-inject on every render and duplicate
the same block from Dashboard.jsx. Move all shared mobile CSS to mobile.css */}
<style>{mobileStyles}</style>
<div style={s.app}>
<nav style={s.nav} className="app-nav">
@@ -248,14 +302,28 @@ export default function App() {
<div className="nav-tabs">
{tabs.map(t => (
<button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
{/* TODO [MINOR #17]: first .replace('📊 ', '📊 ') replaces string with itself — no-op. Remove it. */}
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
</button>
))}
</div>
<button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={s.userBadge} title={`Signed in as ${user.username} (${user.role})`}>
👤 {user.username}
</span>
{user.role === 'admin' && (
<button style={s.navBtn} onClick={() => setShowUsers(true)} title="Manage user accounts">
Users
</button>
)}
<button style={{ ...s.docsBtn, marginLeft: 0 }} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
<span>?</span> Docs
</button>
<button style={s.navBtn} onClick={handleLogout} title="Sign out">
Logout
</button>
</div>
</nav>
<div style={s.main}>
@@ -267,6 +335,7 @@ export default function App() {
<AppFooter version={version} />
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
{showUsers && <UserManagementModal currentUser={user} onClose={() => setShowUsers(false)} />}
</div>
</ToastProvider>
);
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios';
const TOKEN_KEY = 'cpas_token';
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t); }
export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
// Attach the session token to every outgoing request.
axios.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// When the server rejects a token (expired / invalid), drop it and notify the
// app so it can fall back to the login screen.
let onUnauthorized = null;
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
axios.interceptors.response.use(
(res) => res,
(err) => {
if (err.response && err.response.status === 401) {
clearToken();
if (onUnauthorized) onUnauthorized();
}
return Promise.reject(err);
}
);
@@ -7,6 +7,7 @@ const FIELD_LABELS = {
details: 'Incident Notes',
submitted_by: 'Submitted By',
witness_name: 'Witness / Documenting Officer',
amount: 'Amount in Question',
};
const s = {
@@ -72,9 +73,13 @@ const s = {
},
};
// SQLite CURRENT_TIMESTAMP is UTC as "YYYY-MM-DD HH:MM:SS" with no zone marker;
// JS Date() would parse that space-separated form as local time and shift it by
// the viewer's UTC offset. Pin the bare SQLite form to UTC before formatting.
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
const utc = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(iso) ? iso.replace(' ', 'T') + 'Z' : iso;
return new Date(utc).toLocaleString('en-US', { timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short' });
}
export default function AmendViolationModal({ violation, onClose, onSaved }) {
@@ -84,6 +89,7 @@ export default function AmendViolationModal({ violation, onClose, onSaved }) {
details: violation.details || '',
submitted_by: violation.submitted_by || '',
witness_name: violation.witness_name || '',
amount: violation.amount || '',
});
const [changedBy, setChangedBy] = useState('');
const [saving, setSaving] = useState(false);
+11 -2
View File
@@ -85,9 +85,14 @@ const s = {
},
};
// SQLite CURRENT_TIMESTAMP returns UTC as "YYYY-MM-DD HH:MM:SS" with no zone
// marker. JS Date() parses that space-separated form as LOCAL time, shifting
// every entry by the viewer's UTC offset (the ~5h drift seen from Chicago/CDT).
// Detect the bare SQLite form and pin it to UTC before formatting.
function fmtDt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
const utc = /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(iso) ? iso.replace(' ', 'T') + 'Z' : iso;
return new Date(utc).toLocaleString('en-US', {
timeZone: 'America/Chicago', dateStyle: 'medium', timeStyle: 'short',
});
}
@@ -112,6 +117,9 @@ export default function AuditLog({ onClose }) {
const [filterAction, setFilterAction] = useState('');
const LIMIT = 50;
// TODO [MAJOR #5]: `offset` in useCallback deps causes the callback to be
// re-created on each load-more, which triggers the filterType/filterAction
// useEffect unexpectedly. Track offset in a useRef instead.
const load = useCallback((reset = false) => {
setLoading(true);
const o = reset ? 0 : offset;
@@ -121,7 +129,8 @@ export default function AuditLog({ onClose }) {
axios.get('/api/audit', { params })
.then(r => {
const data = r.data;
// Client-side action filter (cheap enough at this scale)
// TODO [MINOR]: client-side action filter means server still fetches LIMIT
// rows before filtering — add server-side `action` param to /api/audit.
const filtered = filterAction ? data.filter(e => e.action === filterAction) : data;
setEntries(prev => reset ? filtered : [...prev, ...filtered]);
setHasMore(data.length === LIMIT);
+3 -1
View File
@@ -29,7 +29,8 @@ function isAtRisk(points) {
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
}
// Media query hook
// TODO [MAJOR #8]: Same hook is defined in App.jsx — extract to src/hooks/useMediaQuery.js
// Also: `matches` in the dep array can cause a loop on strict-mode initial mount.
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
@@ -186,6 +187,7 @@ export default function Dashboard() {
return (
<>
{/* TODO [MAJOR #9]: Same mobileStyles block exists in App.jsx. Move to mobile.css */}
<style>{mobileStyles}</style>
<div style={s.wrap} className="dashboard-wrap">
<div style={s.header} className="dashboard-header">
+39 -4
View File
@@ -86,6 +86,11 @@ const s = {
fontSize: '9px', fontWeight: 700, background: '#0e2a2a', color: '#4db6ac',
border: '1px solid #1a4a4a', verticalAlign: 'middle',
},
backfillBtn: {
background: 'none', border: '1px solid #d4af37', color: '#ffd666',
borderRadius: '4px', padding: '4px 10px', fontSize: '11px',
cursor: 'pointer', fontWeight: 600,
},
};
export default function EmployeeModal({ employeeId, onClose }) {
@@ -103,13 +108,12 @@ export default function EmployeeModal({ employeeId, onClose }) {
const load = useCallback(() => {
setLoading(true);
Promise.all([
axios.get('/api/employees'),
axios.get(`/api/employees/${employeeId}`),
axios.get(`/api/employees/${employeeId}/score`),
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
])
.then(([empRes, scoreRes, violRes]) => {
const emp = empRes.data.find((e) => e.id === employeeId);
setEmployee(emp || null);
setEmployee(empRes.data || null);
setScore(scoreRes.data);
setViolations(violRes.data);
})
@@ -157,6 +161,26 @@ export default function EmployeeModal({ employeeId, onClose }) {
}
};
const handleRecomputeSnapshots = async () => {
if (!window.confirm(
'Rebuild the "Prior Active Points" snapshot on every violation for this employee?\n\n' +
'Use this after back-dating a violation if older PDFs no longer reflect the correct prior-points total. ' +
'Existing PDFs will regenerate with up-to-date numbers.'
)) return;
try {
const r = await axios.post(`/api/employees/${employeeId}/recompute-snapshots`);
const { scanned, updated } = r.data;
if (updated === 0) {
toast.success(`Snapshots already up to date (${scanned} checked).`);
} else {
toast.success(`Updated ${updated} of ${scanned} snapshot${updated === 1 ? '' : 's'}.`);
}
load();
} catch (err) {
toast.error('Backfill failed: ' + (err.response?.data?.error || err.message));
}
};
const handleNegate = async ({ resolution_type, details, resolved_by }) => {
try {
await axios.patch(`/api/violations/${negating.id}/negate`, { resolution_type, details, resolved_by });
@@ -252,7 +276,18 @@ export default function EmployeeModal({ employeeId, onClose }) {
)}
{/* ── Active Violations ── */}
<div style={s.sectionHd}>Active Violations</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '24px', marginBottom: '10px' }}>
<div style={{ ...s.sectionHd, marginTop: 0, marginBottom: 0 }}>Active Violations</div>
{violations.length > 0 && (
<button
style={s.backfillBtn}
onClick={handleRecomputeSnapshots}
title="Rebuild prior-points snapshot on each violation. Use after a back-dated insert if older PDFs show the wrong Prior Active Points."
>
Backfill Snapshots
</button>
)}
</div>
{active.length === 0 ? (
<div style={{ color: '#777990', fontStyle: 'italic', fontSize: '12px' }}>
No active violations on record.
+15
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import axios from 'axios';
import { useToast } from './ToastProvider';
const s = {
wrapper: { marginTop: '20px' },
@@ -53,14 +54,23 @@ export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
const [draft, setDraft] = useState(initialNotes || '');
const [saved, setSaved] = useState(initialNotes || '');
const [saving, setSaving] = useState(false);
const [saveErr, setSaveErr] = useState('');
const toast = useToast();
const handleSave = async () => {
setSaving(true);
setSaveErr('');
try {
await axios.patch(`/api/employees/${employeeId}/notes`, { notes: draft });
setSaved(draft);
setEditing(false);
if (onSaved) onSaved(draft);
} catch (err) {
const msg = err.response?.data?.error || err.message || 'Failed to save notes';
setSaveErr(msg);
toast.error('Notes save failed: ' + msg);
// Keep editing open so the user doesn't lose their changes
} finally {
setSaving(false);
}
@@ -130,6 +140,11 @@ export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
placeholder="Free-text notes — one per line or comma-separated. Does not affect CPAS scoring."
autoFocus
/>
{saveErr && (
<div style={{ fontSize: '12px', color: '#ff7070', marginBottom: '6px' }}>
{saveErr}
</div>
)}
<div style={s.actions}>
<button style={s.saveBtn} onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Notes'}
+53 -37
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
// Tier thresholds used to compute what tier an employee would drop to
// after a given violation rolls off.
// TODO [MINOR #10]: This TIER_THRESHOLDS array duplicates tiers defined in CpasBadge.jsx
// and Dashboard.jsx. Export TIERS from CpasBadge.jsx and import here instead.
const TIER_THRESHOLDS = [
{ min: 30, label: 'Separation', color: '#ff1744' },
{ min: 25, label: 'Final Decision', color: '#ff6d00' },
@@ -62,73 +62,89 @@ const s = {
};
export default function ExpirationTimeline({ employeeId, currentPoints }) {
const [items, setItems] = useState([]);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
axios.get(`/api/employees/${employeeId}/expiration`)
.then(r => setItems(r.data))
.then(r => setData(r.data))
.finally(() => setLoading(false));
}, [employeeId]);
if (loading) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={{ ...s.empty }}>Loading</div>
</div>
);
if (items.length === 0) return (
const schedule = data?.schedule || [];
if (schedule.length === 0) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.empty}>No active violations nothing to expire.</div>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={s.empty}>No active points nothing to roll off.</div>
</div>
);
// Build running totals: after each violation expires, what's the remaining score?
let running = currentPoints || 0;
const projected = items.map(item => {
// Points roll off 5 at a time (oldest first) after each 90 consecutive clean
// days. Walk the schedule to project the score and tier after each event.
let running = data.active_points ?? currentPoints ?? 0;
const projected = schedule.map(ev => {
const before = running;
running = Math.max(0, running - item.points);
running = ev.points_after;
const tierBefore = getTier(before);
const tierAfter = getTier(running);
const dropped = tierAfter.min < tierBefore.min;
return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped };
return {
...ev,
pointsBefore: before,
tierBefore,
tierAfter,
tierDropped: tierAfter.min < tierBefore.min,
};
});
return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
{projected.map((item) => {
const color = urgencyColor(item.days_remaining);
const pct = (item.days_remaining / 90) * 100;
<div style={{ ...s.empty, fontStyle: 'normal', marginBottom: '10px', color: '#9ca0b8' }}>
5 points roll off after every 90 consecutive days with no new violation.
Any new violation resets the countdown.
{data.last_violation_date && (
<> Clean since <strong style={{ color: '#f8f9fa' }}>{data.last_violation_date}</strong>.</>
)}
</div>
{projected.map((ev, i) => {
const color = urgencyColor(ev.days_remaining);
const pct = (ev.days_remaining / 90) * 100;
return (
<div key={item.id} style={s.row}>
{/* Violation name */}
<div style={s.name} title={item.violation_name}>{item.violation_name}</div>
<div key={ev.date} style={s.row}>
{/* Roll-off event label */}
<div style={s.name} title={`Roll-off #${i + 1}`}>Roll-off #{i + 1}</div>
{/* Points badge */}
<div style={s.pts}>{item.points}</div>
{/* Points retired */}
<div style={s.pts}>{ev.points_off}</div>
{/* Progress bar: how much of the 90 days has elapsed */}
{/* Progress bar: how much of the 90-day cycle has elapsed */}
<div style={s.bar(pct, color)}>
<div style={s.barFill(pct, color)} />
</div>
{/* Days remaining pill */}
<div style={s.pill(color)}>
{item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`}
{ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
</div>
{/* Expiry date */}
<div style={s.date}>{item.expires_on}</div>
{/* Roll-off date */}
<div style={s.date}>{ev.date}</div>
{/* Tier drop indicator */}
{item.tierDropped && (
{ev.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{item.tierAfter.label}
{ev.tierAfter.label}
</div>
)}
</div>
@@ -138,16 +154,16 @@ export default function ExpirationTimeline({ employeeId, currentPoints }) {
{/* Projection summary */}
<div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each expiration
Projected score after each roll-off
</div>
{projected.map((item, i) => (
<div key={item.id} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{item.expires_on} {item.violation_name}</span>
{projected.map((ev) => (
<div key={ev.date} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{ev.date} {ev.points_off} pts</span>
<span>
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span>
{item.tierDropped && (
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}>
{item.tierAfter.label}
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{ev.points_after} pts</span>
{ev.tierDropped && (
<span style={{ marginLeft: '8px', color: ev.tierAfter.color, fontWeight: 700 }}>
{ev.tierAfter.label}
</span>
)}
</span>
+103
View File
@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import axios from 'axios';
import { setToken } from '../auth';
const s = {
overlay: {
position: 'fixed', inset: 0, background: '#050608',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 3000, fontFamily: "'Segoe UI', Arial, sans-serif",
},
modal: {
width: '380px', maxWidth: '92vw', background: '#111217', borderRadius: '12px',
boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
overflow: 'hidden', border: '1px solid #2a2b3a',
},
header: {
padding: '24px', borderBottom: '1px solid #222', textAlign: 'center',
background: 'linear-gradient(135deg, #000000, #151622)',
},
logo: { height: '34px', marginBottom: '12px' },
title: { fontSize: '18px', fontWeight: 800, letterSpacing: '0.5px' },
subtitle: { fontSize: '12px', color: '#c0c2d6', marginTop: '4px' },
body: { padding: '22px 24px 8px 24px' },
label: { fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
input: {
width: '100%', padding: '10px 12px', borderRadius: '6px',
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
fontSize: '14px', fontFamily: 'inherit', marginBottom: '16px',
boxSizing: 'border-box',
},
error: {
background: '#3a1414', borderRadius: '6px', padding: '9px 11px',
fontSize: '12px', color: '#ff9b9b', border: '1px solid #c0392b', marginBottom: '14px',
},
footer: { padding: '0 24px 22px 24px' },
btn: {
width: '100%', padding: '11px', borderRadius: '6px', border: 'none',
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
color: '#000', fontWeight: 700, fontSize: '14px',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.5px',
},
};
export default function LoginModal({ onSuccess }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data } = await axios.post('/api/auth/login', { username, password });
setToken(data.token);
onSuccess(data.user);
} catch (err) {
setError(err.response?.data?.error || 'Login failed. Please try again.');
setLoading(false);
}
};
return (
<div style={s.overlay}>
<form style={s.modal} onSubmit={submit}>
<div style={s.header}>
<img src="/static/mpm-logo.png" alt="MPM" style={s.logo} />
<div style={s.title}>CPAS Tracker</div>
<div style={s.subtitle}>Sign in to continue</div>
</div>
<div style={s.body}>
{error && <div style={s.error}>{error}</div>}
<div style={s.label}>Username</div>
<input
style={s.input}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
<div style={s.label}>Password</div>
<input
style={s.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div style={s.footer}>
<button style={{ ...s.btn, opacity: loading ? 0.7 : 1 }} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</div>
</form>
</div>
);
}
-2
View File
@@ -78,14 +78,12 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
});
};
// FIX: overlay click only closes on backdrop, NOT modal children
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget && onCancel) onCancel();
};
return (
<div style={s.overlay} onClick={handleOverlayClick}>
{/* FIX: stopPropagation prevents modal clicks from bubbling to overlay */}
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
<div style={s.header}>
+68 -7
View File
@@ -83,9 +83,42 @@ Internal tool for CPAS violation documentation, workforce standing management, a
---
## Authentication & User Accounts
The application is protected by a login screen. Every page and every API action requires a valid signed-in session — unauthenticated visitors cannot view or edit any CPAS data.
### Signing in
Open the app and enter your username and password in the login popup. A session lasts **7 days**, after which you'll be asked to sign in again. Use the **Logout** button in the top-right of the nav bar to end your session immediately.
### The bootstrap admin
A single admin account is created automatically from the container's environment variables (\`ADMIN_USERNAME\` / \`ADMIN_PASSWORD\`). Notes:
- Its password is **owned by the Docker environment** — it re-syncs on every container start. To rotate it, change \`ADMIN_PASSWORD\` in the container settings and restart. It cannot be changed from the UI (a UI change would be overwritten on the next restart).
- Set a strong \`ADMIN_PASSWORD\` at deploy time. The image ships with a placeholder default that must be overridden.
### Managing users (admin only)
Admins see a **Users** button in the nav bar that opens the user-management panel. From there you can:
- **Add a user** — username, password (min 6 characters), and a role of **User** or **Admin**.
- **Reset a password** for any account.
- **Delete a user** — they lose access immediately. You cannot delete your own account.
Roles: **Admin** can manage users plus everything a User can do; **User** can view and edit CPAS data but cannot manage accounts. Login attempts and all account changes are written to the audit log.
---
## How Scoring Works
Every violation carries a **point value** set at the time of submission. Points count toward an employee's score only within a **rolling 90-day window** — once a violation is older than 90 days it automatically drops off and the score recalculates.
Every violation carries a **point value** set at the time of submission. Points do **not** each expire on their own 90-day timer. Instead, CPAS uses a **clean-cycle roll-off**:
- An employee's points start retiring only after they go a full **90 consecutive days with no new violation**.
- **Any new violation resets that 90-day clock** — the whole balance stays put and the countdown starts over.
- Each completed 90-day clean stretch removes **5 points, oldest violation first**. Dropping another 5 requires another full 90 clean days.
In short: staying clean is what burns points down, and a single new incident pushes everyone's roll-off back to square one.
Negated (voided) violations are excluded from scoring immediately. Hard-deleted violations are removed from the record entirely.
@@ -145,13 +178,13 @@ Shows current tier badge, active points, and 90-day violation count.
#### Notes & Flags
Free-text field for HR context (e.g. "On PIP", "Union member", "Pending investigation", "FMLA"). Quick-add tag buttons pre-fill common statuses. Notes are visible to anyone who opens the profile but **do not affect CPAS scoring**. Edit inline; saves on blur.
#### Point Expiration Timeline
Visible when the employee has active points. Shows each active violation as a progress bar indicating how far through its 90-day window it is, days remaining until roll-off, and a **tier-drop indicator** for violations whose expiration would move the employee down a tier.
#### Point Roll-Off Timeline
Visible when the employee has active points. Lists each future roll-off event (Roll-off #1, #2, …) — how many points retire, a progress bar and days-remaining countdown to that event, the projected date, and a **tier-drop indicator** where a roll-off would move the employee down a tier. It reflects the clean-cycle model: the countdown is anchored to the last violation date, and logging a new violation resets it.
#### Violation History
Full record of all submissions — active, negated, and resolved.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Amend** — edit non-scoring fields (location, details, witness, submitted-by, incident time, acknowledged-by, acknowledged-date, amount) on any active violation. Every change is logged as a field-level diff (old → new) with timestamp. Points, type, and incident date are immutable.
- **Negate** — soft-delete a violation with a resolution type and notes. The record is preserved in history; the points are immediately removed from the score. Fully reversible via **Restore**.
- **Hard delete** — permanent removal. Use only for genuine data entry errors.
- **PDF** — download the formal violation document for any historical record. If the violation has an employee acknowledgment on record, the PDF shows the filled-in name and date instead of blank signature lines.
@@ -164,6 +197,31 @@ Update name, department, or supervisor. Changes are logged to the audit trail.
#### Merge Duplicate
If the same employee exists under two names, use Merge to reassign all violations from the duplicate to the canonical record. The duplicate is then deleted. This cannot be undone.
#### Backfill Snapshots (repair tool)
Each violation stores a **prior-points snapshot** at submission time so its PDF always shows the score *as it was on the incident date*. Normally you never touch this — it's set on insert, refreshed automatically when a back-dated violation lands inside another violation's 90-day window, and otherwise locked. PDFs stay stable through negate/restore by design.
The **↻ Backfill Snapshots** button sits next to the **Active Violations** header in the profile modal. It rebuilds the snapshot on every violation for that employee using current data.
**When to use it:**
- After back-dating a violation, an older PDF still shows "Prior Active Points: 0" even though an earlier violation now clearly exists in the timeline.
- More generally: any time a regenerated PDF disagrees with what you see in the Point Expiration Timeline (the timeline is computed live; PDFs use the snapshot).
**When *not* to use it:**
- After a negate, restore, amend, or hard delete in normal workflow. The system intentionally keeps existing PDFs stable through those operations.
- As a routine maintenance step. If you keep needing it after ordinary back-dated inserts, that's a bug worth reporting — the auto-refresh should already be covering you.
**What clicking it does:**
1. Iterates every violation belonging to the employee (active and negated).
2. Recomputes each row's prior-points snapshot from the current set of non-negated violations in the 90 days before its incident date.
3. Writes only the rows that actually changed.
4. Records one entry in the audit log (action: \`violation_snapshots_recomputed\`, reason: \`manual_backfill\`) with the per-row before/after values.
A toast confirms the outcome — either *"Updated X of Y snapshots"* or *"Snapshots already up to date"*. Re-download any affected PDFs after running it; the new totals will appear immediately.
---
### Audit Log
@@ -182,7 +240,7 @@ The audit log is the authoritative record for compliance review. Nothing in it c
Amendments allow corrections to a violation's non-scoring fields without deleting and re-submitting, which would disrupt the audit trail and the prior-points snapshot.
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date.
**Amendable fields:** incident time, location, details, submitted-by, witness name, acknowledged-by, acknowledged-date, amount (dollar figure on financial violations).
**Immutable fields:** violation type, incident date, point value.
@@ -217,6 +275,7 @@ Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has
| Edit employee name / dept / supervisor | Yes | Logged to audit trail |
| Merge duplicate employees | Yes | Irreversible |
| Add / edit employee notes | Yes | Does not affect score |
| Recompute prior-points snapshot | Yes | Two paths only: auto (back-dated insert) or **↻ Backfill Snapshots** button. Never touched by negate, restore, amend, or hard delete |
---
@@ -237,6 +296,8 @@ Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has
- In-app admin guide (this panel)
- Acknowledgment signature field — employee name + date on form and PDF
- Toast notification system — global feedback for all user actions
- Backfill Snapshots — per-employee repair tool that rebuilds the prior-points snapshot on every violation when older PDFs drift from current data
- Authentication — login screen gating all routes, env-bootstrapped admin account, and admin-managed user accounts with admin/user roles
---
@@ -245,7 +306,7 @@ Toasts auto-dismiss after a few seconds (errors persist longer). Each toast has
These are well-scoped additions that fit the current architecture without major changes.
- **CSV export** — one endpoint returning violations or dashboard data as a downloadable CSV for payroll or external reporting.
- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments without requiring full auth.
- **Supervisor-scoped view** — filter the dashboard to a single supervisor's team via URL param; useful in multi-supervisor environments.
---
@@ -264,7 +325,7 @@ Larger features that require more design work or infrastructure.
These require meaningful infrastructure additions and should be evaluated against actual operational need before committing.
- **Multi-user auth** — role-based login (admin, supervisor, read-only). Currently the app assumes a trusted internal network with no authentication layer.
- **Expanded roles** — finer-grained permissions beyond the current admin/user split (e.g. supervisor-scoped or read-only roles).
- **Tier escalation alerts** — email or in-app notification when an employee crosses into Tier 2+, automatically routed to their supervisor.
- **Scheduled digest** — weekly email summary to supervisors showing their employees' current standings and any approaching thresholds.
- **Automated DB backup** — scheduled snapshot of the database to a mounted backup volume or remote destination.
@@ -0,0 +1,171 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const s = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2500,
},
modal: {
width: '560px', maxWidth: '95vw', maxHeight: '90vh', background: '#111217',
borderRadius: '12px', boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
overflow: 'hidden', border: '1px solid #2a2b3a', display: 'flex', flexDirection: 'column',
},
header: {
padding: '18px 24px', borderBottom: '1px solid #222',
background: 'linear-gradient(135deg, #000000, #151622)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
},
title: { fontSize: '18px', fontWeight: 700 },
close: { background: 'none', border: 'none', color: '#9ca0b8', fontSize: '22px', cursor: 'pointer', lineHeight: 1 },
body: { padding: '18px 24px', overflowY: 'auto' },
error: {
background: '#3a1414', borderRadius: '6px', padding: '9px 11px',
fontSize: '12px', color: '#ff9b9b', border: '1px solid #c0392b', marginBottom: '14px',
},
table: { width: '100%', borderCollapse: 'collapse', marginBottom: '20px', fontSize: '13px' },
th: { textAlign: 'left', padding: '8px 10px', color: '#9ca0b8', borderBottom: '1px solid #222', fontWeight: 600 },
td: { padding: '8px 10px', borderBottom: '1px solid #1a1b22' },
roleBadge: (admin) => ({
fontSize: '11px', fontWeight: 700, padding: '2px 8px', borderRadius: '10px',
background: admin ? '#3b2e00' : '#1a2733', color: admin ? '#ffd666' : '#7fc4ff',
border: `1px solid ${admin ? '#d4af37' : '#2a4a66'}`,
}),
smallBtn: {
padding: '4px 10px', borderRadius: '5px', border: '1px solid #333544',
background: '#050608', color: '#f8f9fa', fontSize: '12px', cursor: 'pointer', marginRight: '6px',
},
dangerBtn: {
padding: '4px 10px', borderRadius: '5px', border: '1px solid #5a2020',
background: '#2a1010', color: '#ff9b9b', fontSize: '12px', cursor: 'pointer',
},
sectionTitle: { fontSize: '14px', fontWeight: 700, margin: '6px 0 12px 0', color: '#e5e7f1' },
row: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' },
field: { flex: '1 1 140px' },
label: { fontSize: '12px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
input: {
width: '100%', padding: '8px 10px', borderRadius: '6px',
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
fontSize: '13px', fontFamily: 'inherit', boxSizing: 'border-box',
},
addBtn: {
padding: '9px 18px', borderRadius: '6px', border: 'none',
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
color: '#000', fontWeight: 700, fontSize: '13px', cursor: 'pointer',
},
};
export default function UserManagementModal({ currentUser, onClose }) {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('user');
const load = useCallback(async () => {
try {
const { data } = await axios.get('/api/users');
setUsers(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load users');
}
}, []);
useEffect(() => { load(); }, [load]);
const addUser = async (e) => {
e.preventDefault();
setError('');
try {
await axios.post('/api/users', { username, password, role });
setUsername(''); setPassword(''); setRole('user');
load();
} catch (err) {
setError(err.response?.data?.error || 'Failed to create user');
}
};
const removeUser = async (u) => {
if (!window.confirm(`Delete user "${u.username}"? They will lose access immediately.`)) return;
setError('');
try {
await axios.delete(`/api/users/${u.id}`);
load();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete user');
}
};
const resetPassword = async (u) => {
const pw = window.prompt(`Enter a new password for "${u.username}" (min 6 characters):`);
if (pw == null) return;
setError('');
try {
await axios.patch(`/api/users/${u.id}/password`, { password: pw });
window.alert('Password updated.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to update password');
}
};
return (
<div style={s.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
<div style={s.header}>
<div style={s.title}>User Management</div>
<button style={s.close} onClick={onClose} title="Close">×</button>
</div>
<div style={s.body}>
{error && <div style={s.error}>{error}</div>}
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Username</th>
<th style={s.th}>Role</th>
<th style={s.th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td style={s.td}>
{u.username}{u.id === currentUser?.id && <span style={{ color: '#9ca0b8' }}> (you)</span>}
</td>
<td style={s.td}><span style={s.roleBadge(u.role === 'admin')}>{u.role}</span></td>
<td style={s.td}>
<button style={s.smallBtn} onClick={() => resetPassword(u)}>Reset Password</button>
{u.id !== currentUser?.id && (
<button style={s.dangerBtn} onClick={() => removeUser(u)}>Delete</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div style={s.sectionTitle}>Add New User</div>
<form style={s.row} onSubmit={addUser}>
<div style={s.field}>
<div style={s.label}>Username</div>
<input style={s.input} value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div style={s.field}>
<div style={s.label}>Password</div>
<input style={s.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
<div style={{ ...s.field, flex: '0 0 110px' }}>
<div style={s.label}>Role</div>
<select style={s.input} value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button style={s.addBtn} type="submit">Add User</button>
</form>
</div>
</div>
</div>
);
}
+128 -7
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
import { violationData, violationGroups } from '../data/violations';
import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence';
import CpasBadge from './CpasBadge';
import TierWarning from './TierWarning';
import ViolationHistory from './ViolationHistory';
import ViolationTypeModal from './ViolationTypeModal';
import { useToast } from './ToastProvider';
import { DEPARTMENTS } from '../data/departments';
@@ -43,17 +44,75 @@ export default function ViolationForm() {
const [employees, setEmployees] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [violation, setViolation] = useState(null);
const [status, setStatus] = useState(null);
const [status, setStatus] = useState(null); // TODO [MAJOR #7]: remove — toast covers this
const [lastViolId, setLastViolId] = useState(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [customTypes, setCustomTypes] = useState([]);
const [typeModal, setTypeModal] = useState(null); // null | 'create' | <editing object>
const toast = useToast();
const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
fetchCustomTypes();
}, []);
const fetchCustomTypes = () => {
axios.get('/api/violation-types').then(r => setCustomTypes(r.data)).catch(() => {});
};
// Build a map of custom types keyed by type_key for fast lookup
const customTypeMap = useMemo(() =>
Object.fromEntries(customTypes.map(t => [t.type_key, t])),
[customTypes]
);
// Merge hardcoded and custom violation groups for the dropdown
const mergedGroups = useMemo(() => {
const groups = {};
// Start with all hardcoded groups
Object.entries(violationGroups).forEach(([cat, items]) => {
groups[cat] = [...items];
});
// Add custom types into their respective category, or create new group
customTypes.forEach(t => {
const item = {
key: t.type_key,
name: t.name,
category: t.category,
minPoints: t.min_points,
maxPoints: t.max_points,
chapter: t.chapter || '',
description: t.description || '',
fields: t.fields,
isCustom: true,
customId: t.id,
};
if (!groups[t.category]) groups[t.category] = [];
groups[t.category].push(item);
});
return groups;
}, [customTypes]);
// Resolve a violation definition from either the hardcoded registry or custom types
const resolveViolation = key => {
if (violationData[key]) return violationData[key];
const ct = customTypeMap[key];
if (ct) return {
name: ct.name,
category: ct.category,
chapter: ct.chapter || '',
description: ct.description || '',
minPoints: ct.min_points,
maxPoints: ct.max_points,
fields: ct.fields,
isCustom: true,
customId: ct.id,
};
return null;
};
useEffect(() => {
if (!violation || !form.violationType) return;
const allTime = intel.countsAllTime[form.violationType];
@@ -72,7 +131,7 @@ export default function ViolationForm() {
const handleViolationChange = e => {
const key = e.target.value;
const v = violationData[key] || null;
const v = resolveViolation(key);
setViolation(v);
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
};
@@ -99,6 +158,7 @@ export default function ViolationForm() {
witness_name: form.witnessName || null,
acknowledged_by: form.acknowledgedBy || null,
acknowledged_date: form.acknowledgedDate || null,
amount: form.amount || null,
});
const newId = violRes.data.id;
@@ -108,6 +168,7 @@ export default function ViolationForm() {
setEmployees(empList.data);
toast.success(`Violation #${newId} recorded — click Download PDF to save the document.`);
// TODO [MAJOR #7]: remove setStatus — toast above already covers this message
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
setForm(EMPTY_FORM);
setViolation(null);
@@ -154,7 +215,7 @@ export default function ViolationForm() {
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
<CpasBadge points={intel.score.active_points} />
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
{intel.score.violation_count} active violation{intel.score.violation_count !== 1 ? 's' : ''}
</span>
</div>
)}
@@ -196,16 +257,37 @@ export default function ViolationForm() {
<div style={s.grid}>
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
<label style={{ ...s.label, marginBottom: 0 }}>Violation Type:</label>
<div style={{ display: 'flex', gap: '6px' }}>
{violation?.isCustom && (
<button
type="button"
onClick={() => setTypeModal(customTypeMap[form.violationType])}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #4caf50', background: '#1a2e1a', color: '#4caf50', cursor: 'pointer', fontWeight: 600 }}
>
Edit Type
</button>
)}
<button
type="button"
onClick={() => setTypeModal('create')}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #d4af37', background: '#181200', color: '#ffd666', cursor: 'pointer', fontWeight: 600 }}
title="Add a new custom violation type"
>
+ Add Type
</button>
</div>
</div>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
<option value="">-- Select Violation Type --</option>
{Object.entries(violationGroups).map(([group, items]) => (
{Object.entries(mergedGroups).map(([group, items]) => (
<optgroup key={group} label={group}>
{items.map(v => {
const prior = priorCount90(v.key);
return (
<option key={v.key} value={v.key}>
{v.name}{prior > 0 ? `${prior}x in 90 days` : ''}
{v.name}{v.isCustom ? ' ✦' : ''}{prior > 0 ? `${prior}x in 90 days` : ''}
</option>
);
})}
@@ -216,6 +298,11 @@ export default function ViolationForm() {
{violation && (
<div style={s.contextBox}>
<strong>{violation.name}</strong>
{violation.isCustom && (
<span style={{ display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50' }}>
Custom
</span>
)}
{isRepeat(form.violationType) && form.employeeId && (
<span style={s.repeatBadge}>
Repeat {intel.countsAllTime[form.violationType]?.count}x prior
@@ -348,6 +435,40 @@ export default function ViolationForm() {
</div>
)}
{typeModal && (
<ViolationTypeModal
editing={typeModal === 'create' ? null : typeModal}
onClose={() => setTypeModal(null)}
onSaved={saved => {
fetchCustomTypes();
setTypeModal(null);
// Auto-select the newly created type; do nothing on delete (saved === null)
if (saved) {
const v = {
name: saved.name,
category: saved.category,
chapter: saved.chapter || '',
description: saved.description || '',
minPoints: saved.min_points,
maxPoints: saved.max_points,
fields: saved.fields,
isCustom: true,
customId: saved.id,
};
setViolation(v);
setForm(prev => ({ ...prev, violationType: saved.type_key, points: saved.min_points }));
} else {
// Type was deleted — clear selection if it was the active type
setForm(prev => {
const stillExists = violationData[prev.violationType] || false;
return stillExists ? prev : { ...prev, violationType: '', points: 1 };
});
setViolation(null);
}
}}
/>
)}
</div>
);
}
@@ -0,0 +1,292 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { useToast } from './ToastProvider';
// Existing hardcoded categories — used for datalist autocomplete
const KNOWN_CATEGORIES = [
'Attendance & Punctuality',
'Administrative Integrity',
'Financial Stewardship',
'Operational Response',
'Professional Conduct',
'Work From Home',
'Safety & Security',
];
const CONTEXT_FIELDS = [
{ key: 'time', label: 'Incident Time' },
{ key: 'minutes', label: 'Minutes Late' },
{ key: 'amount', label: 'Amount / Value' },
{ key: 'location', label: 'Location / Context' },
{ key: 'description', label: 'Additional Details' },
];
const s = {
overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px' },
modal: { background: '#111217', border: '1px solid #2a2b3a', borderRadius: '10px', width: '100%', maxWidth: '620px', maxHeight: '90vh', overflowY: 'auto', padding: '32px' },
title: { color: '#f8f9fa', fontSize: '20px', fontWeight: 700, marginBottom: '24px', borderBottom: '1px solid #2a2b3a', paddingBottom: '12px' },
label: { fontWeight: 600, color: '#e5e7f1', marginBottom: '5px', fontSize: '13px', display: 'block' },
input: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '14px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', boxSizing: 'border-box' },
textarea: { width: '100%', padding: '10px', border: '1px solid #333544', borderRadius: '4px', fontSize: '13px', fontFamily: 'inherit', background: '#050608', color: '#f8f9fa', resize: 'vertical', minHeight: '80px', boxSizing: 'border-box' },
group: { marginBottom: '18px' },
hint: { fontSize: '11px', color: '#9ca0b8', marginTop: '4px', fontStyle: 'italic' },
row: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' },
toggle: { display: 'flex', gap: '8px', marginTop: '6px' },
toggleBtn: (active) => ({
padding: '7px 18px', borderRadius: '4px', fontSize: '13px', fontWeight: 600, cursor: 'pointer', border: '1px solid',
background: active ? '#d4af37' : '#050608',
color: active ? '#000' : '#9ca0b8',
borderColor: active ? '#d4af37' : '#333544',
}),
fieldGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginTop: '8px' },
checkbox: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: '#d1d3e0', cursor: 'pointer' },
btnRow: { display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '28px', paddingTop: '16px', borderTop: '1px solid #2a2b3a' },
btnSave: { padding: '10px 28px', fontSize: '14px', fontWeight: 600, border: 'none', borderRadius: '6px', cursor: 'pointer', background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)', color: '#000' },
btnDanger: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #721c24', borderRadius: '6px', cursor: 'pointer', background: '#3c1114', color: '#ffb3b8' },
btnCancel: { padding: '10px 18px', fontSize: '14px', fontWeight: 600, border: '1px solid #333544', borderRadius: '6px', cursor: 'pointer', background: '#050608', color: '#f8f9fa' },
section: { background: '#181924', border: '1px solid #2a2b3a', borderRadius: '6px', padding: '16px', marginBottom: '18px' },
secTitle: { color: '#d4af37', fontSize: '13px', fontWeight: 700, marginBottom: '12px', textTransform: 'uppercase', letterSpacing: '0.05em' },
customBadge: { display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50', verticalAlign: 'middle' },
};
const EMPTY = {
name: '', category: '', chapter: '', description: '',
pointType: 'fixed', // 'fixed' | 'sliding'
fixedPoints: 1,
minPoints: 1,
maxPoints: 5,
fields: ['description'],
};
export default function ViolationTypeModal({ onClose, onSaved, editing = null }) {
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const toast = useToast();
// Populate form when editing an existing type
useEffect(() => {
if (editing) {
const isSliding = editing.min_points !== editing.max_points;
setForm({
name: editing.name,
category: editing.category,
chapter: editing.chapter || '',
description: editing.description || '',
pointType: isSliding ? 'sliding' : 'fixed',
fixedPoints: isSliding ? editing.min_points : editing.min_points,
minPoints: editing.min_points,
maxPoints: editing.max_points,
fields: editing.fields || ['description'],
});
}
}, [editing]);
const set = (key, val) => setForm(prev => ({ ...prev, [key]: val }));
const toggleField = key => {
setForm(prev => ({
...prev,
fields: prev.fields.includes(key)
? prev.fields.filter(f => f !== key)
: [...prev.fields, key],
}));
};
const handleSave = async () => {
if (!form.name.trim()) { toast.warning('Violation name is required.'); return; }
if (!form.category.trim()) { toast.warning('Category is required.'); return; }
const minPts = form.pointType === 'fixed' ? parseInt(form.fixedPoints) || 1 : parseInt(form.minPoints) || 1;
const maxPts = form.pointType === 'fixed' ? minPts : parseInt(form.maxPoints) || 1;
if (maxPts < minPts) { toast.warning('Max points must be >= min points.'); return; }
if (form.fields.length === 0) { toast.warning('Select at least one context field.'); return; }
const payload = {
name: form.name.trim(),
category: form.category.trim(),
chapter: form.chapter.trim() || null,
description: form.description.trim() || null,
min_points: minPts,
max_points: maxPts,
fields: form.fields,
};
setSaving(true);
try {
let saved;
if (editing) {
const res = await axios.put(`/api/violation-types/${editing.id}`, payload);
saved = res.data;
toast.success(`"${saved.name}" updated.`);
} else {
const res = await axios.post('/api/violation-types', payload);
saved = res.data;
toast.success(`"${saved.name}" added to violation types.`);
}
onSaved(saved);
} catch (err) {
toast.error(err.response?.data?.error || err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editing) return;
if (!window.confirm(`Delete "${editing.name}"? This cannot be undone and will fail if any violations reference this type.`)) return;
setDeleting(true);
try {
await axios.delete(`/api/violation-types/${editing.id}`);
toast.success(`"${editing.name}" deleted.`);
onSaved(null); // null signals a deletion to the parent
} catch (err) {
toast.error(err.response?.data?.error || err.message);
} finally {
setDeleting(false);
}
};
return (
<div style={s.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={s.modal}>
<div style={s.title}>
{editing ? 'Edit Violation Type' : 'Add Violation Type'}
{editing && <span style={s.customBadge}>CUSTOM</span>}
</div>
{/* Basic Info */}
<div style={s.section}>
<div style={s.secTitle}>Violation Definition</div>
<div style={s.group}>
<label style={s.label}>Violation Name *</label>
<input
style={s.input}
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. Unauthorized System Access"
/>
</div>
<div style={s.group}>
<label style={s.label}>Category *</label>
<input
style={s.input}
type="text"
list="vt-categories"
value={form.category}
onChange={e => set('category', e.target.value)}
placeholder="Select existing or type new category"
/>
<datalist id="vt-categories">
{KNOWN_CATEGORIES.map(c => <option key={c} value={c} />)}
</datalist>
<div style={s.hint}>Choose an existing category or type a new one to create a new group in the dropdown.</div>
</div>
<div style={s.group}>
<label style={s.label}>Handbook Reference / Chapter</label>
<input
style={s.input}
type="text"
value={form.chapter}
onChange={e => set('chapter', e.target.value)}
placeholder="e.g. Chapter 4, Section 6"
/>
</div>
<div style={s.group}>
<label style={s.label}>Description / Reference Text</label>
<textarea
style={s.textarea}
value={form.description}
onChange={e => set('description', e.target.value)}
placeholder="Paste the relevant handbook language or describe the infraction in plain terms..."
/>
<div style={s.hint}>Shown in the context box on the violation form and printed on the PDF.</div>
</div>
</div>
{/* Point Assignment */}
<div style={s.section}>
<div style={s.secTitle}>Point Assignment</div>
<label style={s.label}>Point Type</label>
<div style={s.toggle}>
<button type="button" style={s.toggleBtn(form.pointType === 'fixed')} onClick={() => set('pointType', 'fixed')}>Fixed</button>
<button type="button" style={s.toggleBtn(form.pointType === 'sliding')} onClick={() => set('pointType', 'sliding')}>Sliding Range</button>
</div>
<div style={{ ...s.hint, marginTop: '6px' }}>
Fixed = exact value every time. Sliding = supervisor adjusts within a min/max range.
</div>
{form.pointType === 'fixed' ? (
<div style={{ ...s.group, marginTop: '14px' }}>
<label style={s.label}>Points (Fixed)</label>
<input
style={{ ...s.input, width: '120px' }}
type="number" min="1" max="30"
value={form.fixedPoints}
onChange={e => set('fixedPoints', e.target.value)}
/>
</div>
) : (
<div style={{ ...s.row, marginTop: '14px' }}>
<div style={s.group}>
<label style={s.label}>Min Points</label>
<input
style={s.input}
type="number" min="1" max="30"
value={form.minPoints}
onChange={e => set('minPoints', e.target.value)}
/>
</div>
<div style={s.group}>
<label style={s.label}>Max Points</label>
<input
style={s.input}
type="number" min="1" max="30"
value={form.maxPoints}
onChange={e => set('maxPoints', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Context Fields */}
<div style={s.section}>
<div style={s.secTitle}>Context Fields</div>
<div style={s.hint}>Select which additional fields appear on the violation form for this type.</div>
<div style={s.fieldGrid}>
{CONTEXT_FIELDS.map(({ key, label }) => (
<label key={key} style={s.checkbox}>
<input
type="checkbox"
checked={form.fields.includes(key)}
onChange={() => toggleField(key)}
/>
{label}
</label>
))}
</div>
</div>
<div style={s.btnRow}>
{editing && (
<button type="button" style={s.btnDanger} onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete Type'}
</button>
)}
<button type="button" style={s.btnCancel} onClick={onClose}>Cancel</button>
<button type="button" style={s.btnSave} onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : editing ? 'Save Changes' : 'Add Violation Type'}
</button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ export const violationData = {
},
pto_exhausted: {
name: 'Absence - PTO Exhausted', category: 'Attendance & Punctuality',
minPoints: 5, maxPoints: 5, chapter: 'Chapter 4, Section 5',
minPoints: 1, maxPoints: 5, chapter: 'Chapter 4, Section 5',
fields: ['description'],
description: 'Any absence after PTO bank reaches zero'
},
+43 -11
View File
@@ -21,6 +21,8 @@ if (!cols.includes('prior_active_points')) db.exec("ALTER TABLE violations ADD C
if (!cols.includes('prior_tier_label')) db.exec("ALTER TABLE violations ADD COLUMN prior_tier_label TEXT");
if (!cols.includes('acknowledged_by')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_by TEXT");
if (!cols.includes('acknowledged_date')) db.exec("ALTER TABLE violations ADD COLUMN acknowledged_date TEXT");
// Financial amount in question (record-keeping / repayment for chargeback, receipt negligence, etc.)
if (!cols.includes('amount')) db.exec("ALTER TABLE violations ADD COLUMN amount TEXT");
// Employee notes column (free-text, does not affect scoring)
const empCols = db.prepare('PRAGMA table_info(employees)').all().map(c => c.name);
@@ -60,17 +62,47 @@ db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Recreate view so it always filters negated rows
db.exec(`DROP VIEW IF EXISTS active_cpas_scores;
CREATE VIEW active_cpas_scores AS
SELECT
employee_id,
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;`);
// ── Feature: Custom Violation Types ──────────────────────────────────────────
// Persisted violation type definitions created via the UI. type_key is prefixed
// with 'custom_' to prevent collisions with hardcoded violation keys.
db.exec(`CREATE TABLE IF NOT EXISTS violation_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'Custom',
chapter TEXT,
description TEXT,
min_points INTEGER NOT NULL DEFAULT 1,
max_points INTEGER NOT NULL DEFAULT 1,
fields TEXT NOT NULL DEFAULT '["description"]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// ── Feature: Authentication ──────────────────────────────────────────────────
// User accounts and login sessions. Passwords are stored as scrypt hashes
// (see auth.js). The bootstrap admin account is created/synced from the
// ADMIN_USERNAME / ADMIN_PASSWORD environment variables on startup.
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
)`);
// The old `active_cpas_scores` view implemented a naive per-violation 90-day
// window. CPAS standing now follows the clean-cycle roll-off model (see
// lib/rolloff.js), which is order-dependent and computed in JS, so the view is
// retired here to keep a single source of truth.
db.exec('DROP VIEW IF EXISTS active_cpas_scores;');
console.log('[DB] Connected:', dbPath);
module.exports = db;
+4 -10
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS violations (
prior_tier_label TEXT, -- optional human-readable tier
acknowledged_by TEXT, -- employee name who acknowledged receipt
acknowledged_date TEXT, -- date of acknowledgment (YYYY-MM-DD)
amount TEXT, -- dollar amount in question for financial violations (record-keeping / repayment)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -37,13 +38,6 @@ CREATE TABLE IF NOT EXISTS violation_resolutions (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Active score: only non-negated violations in rolling 90 days
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
SELECT
employee_id,
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;
-- CPAS standing (active points + roll-off schedule) is computed in JS from the
-- clean-cycle model in lib/rolloff.js; see db/database.js for why the old
-- active_cpas_scores view was retired.
+108
View File
@@ -0,0 +1,108 @@
// ── CPAS point roll-off model ────────────────────────────────────────────────
// Handbook rule: points roll off only after the employee goes a full
// ROLLOFF_DAYS (90) with NO new violations. The clock is shared across all of an
// employee's active points and is RESET by any new non-negated violation. Each
// completed clean cycle removes ROLLOFF_POINTS (5), oldest violation first, and
// every successive roll-off requires another fresh clean cycle.
//
// This is intentionally NOT a per-violation "expires 90 days after its own
// incident date" window — a new violation pushes back roll-off for everything.
const ROLLOFF_POINTS = 5;
const ROLLOFF_DAYS = 90;
const MS_PER_DAY = 86400000;
// All dates are treated as UTC calendar days to match SQLite's DATE('now').
function dayValue(dateStr) {
const [y, m, d] = String(dateStr).slice(0, 10).split('-').map(Number);
return Date.UTC(y, m - 1, d);
}
function daysBetween(fromStr, toStr) {
return Math.round((dayValue(toStr) - dayValue(fromStr)) / MS_PER_DAY);
}
function addDays(dateStr, n) {
return new Date(dayValue(dateStr) + n * MS_PER_DAY).toISOString().slice(0, 10);
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
// Compute an employee's CPAS standing as of `asOf` (default today).
//
// `violations` is an array of rows with at least { id, points, incident_date,
// negated }. Negated rows and rows dated after `asOf` are ignored.
//
// Returns:
// activePoints — points still counting against the employee
// totalPoints — sum of all (non-negated, on-or-before asOf) points
// rolledOffPoints — points already retired by completed clean cycles
// cycles — completed 90-day clean cycles since the last violation
// violationCount — non-negated violations still retaining points
// lastViolationDate — most recent non-negated incident date (anchors the clock)
// nextRolloffDate — date the next 5 points retire (null if nothing left)
// perViolation — oldest-first breakdown: each row + { rolledOff, activePoints }
// schedule — future roll-off events: { date, points_off, points_after, days_remaining }
function computeStanding(violations, asOf = todayStr()) {
const asOfVal = dayValue(asOf);
const active = violations.filter(
v => !v.negated && dayValue(v.incident_date) <= asOfVal
);
if (active.length === 0) {
return {
activePoints: 0, totalPoints: 0, rolledOffPoints: 0, cycles: 0,
violationCount: 0, lastViolationDate: null, nextRolloffDate: null,
perViolation: [], schedule: [],
};
}
const sorted = [...active].sort((a, b) => {
const d = dayValue(a.incident_date) - dayValue(b.incident_date);
return d !== 0 ? d : (a.id - b.id);
});
const totalPoints = sorted.reduce((s, v) => s + v.points, 0);
const lastViolationDate = String(sorted[sorted.length - 1].incident_date).slice(0, 10);
const daysSince = daysBetween(lastViolationDate, asOf);
const cycles = daysSince > 0 ? Math.floor(daysSince / ROLLOFF_DAYS) : 0;
const rolledOffPoints = Math.min(totalPoints, cycles * ROLLOFF_POINTS);
const activePoints = totalPoints - rolledOffPoints;
// Retire points oldest-first.
let toRoll = rolledOffPoints;
const perViolation = sorted.map(v => {
const off = Math.min(v.points, toRoll);
toRoll -= off;
return { ...v, rolledOff: off, activePoints: v.points - off };
});
const violationCount = perViolation.filter(v => v.activePoints > 0).length;
// Project remaining roll-offs forward. Cycle N retires at lastViolation + N*90.
const schedule = [];
let remaining = activePoints;
let c = cycles;
while (remaining > 0) {
c += 1;
const date = addDays(lastViolationDate, c * ROLLOFF_DAYS);
const off = Math.min(ROLLOFF_POINTS, remaining);
remaining -= off;
schedule.push({
date,
points_off: off,
points_after: remaining,
days_remaining: daysBetween(asOf, date),
});
}
return {
activePoints, totalPoints, rolledOffPoints, cycles, violationCount,
lastViolationDate,
nextRolloffDate: schedule.length ? schedule[0].date : null,
perViolation, schedule,
};
}
module.exports = {
ROLLOFF_POINTS, ROLLOFF_DAYS,
computeStanding, daysBetween, addDays, todayStr,
};
+5
View File
@@ -243,6 +243,11 @@ function buildHtml(v, score) {
<div class="field-label">Submitted By</div>
<div class="field-value">${v.submitted_by || 'System'}</div>
</div>
${v.amount ? `
<div class="field" style="grid-column: 1 / -1;">
<div class="field-label">Amount in Question</div>
<div class="field-value prominent">${v.amount}</div>
</div>` : ''}
${v.location ? `
<div class="field" style="grid-column: 1 / -1;">
<div class="field-label">Location / Context</div>
+392 -62
View File
@@ -2,11 +2,16 @@ const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db/database');
const auth = require('./auth');
const generatePdf = require('./pdf/generator');
const { computeStanding } = require('./lib/rolloff');
const app = express();
const PORT = process.env.PORT || 3001;
// Create / sync the bootstrap admin account from ADMIN_USERNAME / ADMIN_PASSWORD.
auth.bootstrapAdmin();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'client', 'dist')));
@@ -29,6 +34,20 @@ function audit(action, entityType, entityId, performedBy, details) {
}
}
// ── CPAS standing helpers ────────────────────────────────────────────────────
// All point/roll-off math lives in lib/rolloff.js. These helpers just feed it
// the employee's non-negated violations and return the computed standing.
function loadViolations(employeeId) {
return db.prepare(
`SELECT id, points, incident_date, negated, violation_name, violation_type, category
FROM violations
WHERE employee_id = ? AND negated = 0`
).all(employeeId);
}
function standingFor(employeeId, asOf) {
return computeStanding(loadViolations(employeeId), asOf);
}
// ── Version info (written by Dockerfile at build time) ───────────────────────
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
@@ -43,12 +62,81 @@ app.get('/api/health', (req, res) => res.json({
version: BUILD_VERSION,
}));
// ── Authentication (public) ──────────────────────────────────────────────────
// POST /api/auth/login — exchange username + password for a session token.
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
const result = auth.login(username, password);
if (!result) {
audit('login_failed', 'user', null, username, {});
return res.status(401).json({ error: 'Invalid username or password' });
}
audit('login_success', 'user', result.user.id, result.user.username, {});
res.json(result);
});
// ── Auth guard ────────────────────────────────────────────────────────────────
// Every /api/* route registered below this line requires a valid session token.
app.use('/api', auth.requireAuth);
// GET /api/auth/me — current session's user
app.get('/api/auth/me', (req, res) => res.json({ user: req.user }));
// POST /api/auth/logout — invalidate the current session token
app.post('/api/auth/logout', (req, res) => {
auth.destroySession(req.authToken);
res.json({ success: true });
});
// ── User management (admin only) ──────────────────────────────────────────────
app.get('/api/users', auth.requireAdmin, (req, res) => {
res.json(auth.listUsers());
});
app.post('/api/users', auth.requireAdmin, (req, res) => {
try {
const user = auth.createUser(req.body || {});
audit('user_created', 'user', user.id, req.user.username, { username: user.username, role: user.role });
res.status(201).json(user);
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
});
app.patch('/api/users/:id/password', auth.requireAdmin, (req, res) => {
try {
const id = parseInt(req.params.id);
if (!auth.setPassword(id, (req.body || {}).password)) return res.status(404).json({ error: 'User not found' });
audit('user_password_reset', 'user', id, req.user.username, {});
res.json({ success: true });
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
});
app.delete('/api/users/:id', auth.requireAdmin, (req, res) => {
const id = parseInt(req.params.id);
if (id === req.user.id) return res.status(400).json({ error: 'You cannot delete your own account' });
if (!auth.deleteUser(id)) return res.status(404).json({ error: 'User not found' });
audit('user_deleted', 'user', id, req.user.username, {});
res.json({ success: true });
});
// ── Employees ────────────────────────────────────────────────────────────────
app.get('/api/employees', (req, res) => {
const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
res.json(rows);
});
// GET /api/employees/:id — single employee record
app.get('/api/employees/:id', (req, res) => {
const emp = db.prepare('SELECT id, name, department, supervisor, notes FROM employees WHERE id = ?').get(req.params.id);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
res.json(emp);
});
app.post('/api/employees', (req, res) => {
const { name, department, supervisor } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
@@ -58,6 +146,9 @@ app.post('/api/employees', (req, res) => {
db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
.run(department || null, supervisor || null, existing.id);
}
// TODO [MINOR #16]: Spreading `existing` then overwriting with possibly-undefined
// `department`/`supervisor` returns `undefined` for unset fields.
// Re-query after update or only spread defined values.
return res.json({ ...existing, department, supervisor });
}
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)')
@@ -148,8 +239,8 @@ app.patch('/api/employees/:id/notes', (req, res) => {
app.get('/api/employees/:id/score', (req, res) => {
const empId = req.params.id;
// Active points from the 90-day rolling view
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId);
// Active points from the clean-cycle roll-off model
const standing = standingFor(empId);
// Total violations (all time) and negated count
const totals = db.prepare(`
@@ -162,50 +253,57 @@ app.get('/api/employees/:id/score', (req, res) => {
res.json({
employee_id: empId,
active_points: active ? active.active_points : 0,
violation_count: active ? active.violation_count : 0,
active_points: standing.activePoints,
violation_count: standing.violationCount,
total_violations: totals ? totals.total_violations : 0,
negated_count: totals ? totals.negated_count : 0,
});
});
// ── Expiration Timeline ──────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — active violations sorted by roll-off date
// Returns each active violation with days_remaining until it exits the 90-day window.
// ── Roll-off Timeline ────────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
// Points only retire after 90 consecutive days with no new violation; any new
// violation resets the clock. Returns the active total plus each future 5-point
// roll-off event (oldest points first) until the balance reaches zero.
app.get('/api/employees/:id/expiration', (req, res) => {
const rows = db.prepare(`
SELECT
v.id,
v.violation_name,
v.violation_type,
v.category,
v.points,
v.incident_date,
DATE(v.incident_date, '+90 days') AS expires_on,
CAST(
JULIANDAY(DATE(v.incident_date, '+90 days')) -
JULIANDAY(DATE('now'))
AS INTEGER
) AS days_remaining
FROM violations v
WHERE v.employee_id = ?
AND v.negated = 0
AND v.incident_date >= DATE('now', '-90 days')
ORDER BY v.incident_date ASC
`).all(req.params.id);
res.json(rows);
const standing = standingFor(req.params.id);
res.json({
active_points: standing.activePoints,
last_violation_date: standing.lastViolationDate,
next_rolloff_date: standing.nextRolloffDate,
schedule: standing.schedule,
});
});
// Dashboard
app.get('/api/dashboard', (req, res) => {
const rows = db.prepare(`
SELECT e.id, e.name, e.department, e.supervisor,
COALESCE(s.active_points, 0) AS active_points,
COALESCE(s.violation_count,0) AS violation_count
FROM employees e
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
ORDER BY active_points DESC, e.name ASC
`).all();
const employees = db.prepare(
'SELECT id, name, department, supervisor FROM employees'
).all();
// Pull every non-negated violation once, bucket by employee, then run the
// roll-off model per employee rather than issuing a query each.
const buckets = new Map();
for (const v of db.prepare(
`SELECT id, employee_id, points, incident_date, negated
FROM violations WHERE negated = 0`
).all()) {
if (!buckets.has(v.employee_id)) buckets.set(v.employee_id, []);
buckets.get(v.employee_id).push(v);
}
const rows = employees.map(e => {
const standing = computeStanding(buckets.get(e.id) || []);
return {
...e,
active_points: standing.activePoints,
violation_count: standing.violationCount,
};
});
rows.sort((a, b) =>
b.active_points - a.active_points || a.name.localeCompare(b.name)
);
res.json(rows);
});
@@ -233,18 +331,96 @@ app.get('/api/violations/:id/amendments', (req, res) => {
res.json(rows);
});
// Helper: compute prior_active_points at time of insert
// Helper: compute prior_active_points at time of insert — the employee's active
// CPAS standing the instant BEFORE this incident, under the clean-cycle model.
// Uses only violations strictly earlier than this incident, evaluated as of the
// incident date.
function getPriorActivePoints(employeeId, incidentDate) {
const row = db.prepare(
`SELECT COALESCE(SUM(points),0) AS pts
const earlier = db.prepare(
`SELECT id, points, incident_date, negated
FROM violations
WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
).all(employeeId, incidentDate);
return computeStanding(earlier, incidentDate).activePoints;
}
// Helper: after a back-dated insert, refresh snapshots on every later
// violation. Under the clean-cycle roll-off model an earlier incident raises
// the running total and shifts the roll-off clock for ALL subsequent
// violations, so their "Prior Active Points" snapshots can all change — not
// just those within 90 days. Snapshots remain immutable w.r.t. negate/restore;
// only timeline-rewriting events (back-dated inserts) trigger a refresh.
function recomputeSnapshotsAfter(employeeId, incidentDate) {
const affected = db.prepare(`
SELECT id, incident_date, prior_active_points
FROM violations
WHERE employee_id = ?
AND negated = 0
AND incident_date >= DATE(?, '-90 days')
AND incident_date < ?`
).get(employeeId, incidentDate, incidentDate);
return row ? row.pts : 0;
AND incident_date > ?
`).all(employeeId, incidentDate);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
const changes = [];
for (const v of affected) {
const newPrior = getPriorActivePoints(employeeId, v.incident_date);
if (newPrior !== v.prior_active_points) {
updateStmt.run(newPrior, v.id);
changes.push({ id: v.id, incident_date: v.incident_date, old: v.prior_active_points, new: newPrior });
}
}
return changes;
}
// Helper: rebuild prior_active_points for every violation belonging to an
// employee, regardless of negate state. Used by the manual backfill button
// to repair snapshots after a back-dated insert that happened under older
// code (before recomputeSnapshotsAfter existed) or any case where the
// per-row snapshot has drifted from current data.
function recomputeAllSnapshotsForEmployee(employeeId) {
const rows = db.prepare(`
SELECT id, incident_date, prior_active_points
FROM violations
WHERE employee_id = ?
ORDER BY incident_date ASC
`).all(employeeId);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
const changes = [];
for (const v of rows) {
const newPrior = getPriorActivePoints(employeeId, v.incident_date);
if (newPrior !== v.prior_active_points) {
updateStmt.run(newPrior, v.id);
changes.push({ id: v.id, incident_date: v.incident_date, old: v.prior_active_points, new: newPrior });
}
}
return { scanned: rows.length, changes };
}
// POST /api/employees/:id/recompute-snapshots
// Manual backfill — rebuild prior_active_points for every violation on this
// employee. Use after a back-dated insert under older code left downstream
// PDFs showing stale "Prior Active Points".
app.post('/api/employees/:id/recompute-snapshots', (req, res) => {
const empId = parseInt(req.params.id);
const emp = db.prepare('SELECT id, name FROM employees WHERE id = ?').get(empId);
if (!emp) return res.status(404).json({ error: 'Employee not found' });
const result = db.transaction(() => recomputeAllSnapshotsForEmployee(empId))();
if (result.changes.length > 0) {
audit('violation_snapshots_recomputed', 'employee', empId, req.body?.performed_by, {
reason: 'manual_backfill',
scanned: result.scanned,
affected: result.changes,
});
}
res.json({
success: true,
scanned: result.scanned,
updated: result.changes.length,
changes: result.changes,
});
});
// POST new violation
app.post('/api/violations', (req, res) => {
@@ -252,7 +428,8 @@ app.post('/api/violations', (req, res) => {
employee_id, violation_type, violation_name, category,
points, incident_date, incident_time, location,
details, submitted_by, witness_name,
acknowledged_by, acknowledged_date
acknowledged_by, acknowledged_date,
amount
} = req.body;
if (!employee_id || !violation_type || !points || !incident_date) {
@@ -260,35 +437,67 @@ app.post('/api/violations', (req, res) => {
}
const ptsInt = parseInt(points);
const priorPts = getPriorActivePoints(employee_id, incident_date);
// Insert + downstream snapshot refresh run in a single transaction so a
// failed recompute can't leave the system with a new violation and stale
// sibling snapshots.
const insertTxn = db.transaction(() => {
const priorPts = getPriorActivePoints(employee_id, 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,
prior_active_points,
acknowledged_by, acknowledged_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
acknowledged_by, acknowledged_date,
amount
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
employee_id, violation_type, violation_name || violation_type,
category || 'General', ptsInt, incident_date,
incident_time || null, location || null,
details || null, submitted_by || null, witness_name || null,
priorPts,
acknowledged_by || null, acknowledged_date || null
acknowledged_by || null, acknowledged_date || null,
amount || null
);
const refreshed = recomputeSnapshotsAfter(employee_id, incident_date);
return { id: result.lastInsertRowid, refreshed };
});
audit('violation_created', 'violation', result.lastInsertRowid, submitted_by, {
const { id: newId, refreshed } = insertTxn();
audit('violation_created', 'violation', newId, submitted_by, {
employee_id, violation_type, points: ptsInt, incident_date,
});
res.status(201).json({ id: result.lastInsertRowid });
// Back-dated insert: log the snapshot refresh so the audit trail explains
// why downstream violations' PDFs now show different "Prior Active Points".
if (refreshed.length > 0) {
audit('violation_snapshots_recomputed', 'violation', newId, submitted_by, {
reason: 'backdated_insert',
trigger_incident_date: incident_date,
affected: refreshed,
});
}
res.status(201).json({ id: newId });
});
// ── Violation Amendment (edit) ───────────────────────────────────────────────
// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field
const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_date'];
const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_date', 'amount'];
// Pre-build one prepared UPDATE statement per amendable field combination is not
// practical (2^n combos), so instead we validate columns against the static
// whitelist and build the clause only from known-safe names at startup.
// The whitelist itself is the guard; no user-supplied column name ever enters SQL.
const AMEND_UPDATE_STMTS = Object.fromEntries(
AMENDABLE_FIELDS.map(f => [
f,
db.prepare(`UPDATE violations SET ${f} = ? WHERE id = ?`)
])
);
app.patch('/api/violations/:id/amend', (req, res) => {
const id = parseInt(req.params.id);
@@ -307,18 +516,14 @@ app.patch('/api/violations/:id/amend', (req, res) => {
}
const amendTransaction = db.transaction(() => {
// Build UPDATE
const setClauses = Object.keys(allowed).map(k => `${k} = ?`).join(', ');
const values = [...Object.values(allowed), id];
db.prepare(`UPDATE violations SET ${setClauses} WHERE id = ?`).run(...values);
// Insert an amendment record per changed field
const insertAmendment = db.prepare(`
INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value)
VALUES (?, ?, ?, ?, ?)
`);
for (const [field, newVal] of Object.entries(allowed)) {
const oldVal = violation[field];
// Use the pre-built statement for this field — no runtime interpolation
AMEND_UPDATE_STMTS[field].run(newVal, id);
if (String(oldVal) !== String(newVal)) {
insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null);
}
@@ -391,6 +596,38 @@ app.delete('/api/violations/:id', (req, res) => {
res.json({ success: true });
});
// ── Violation counts per employee ────────────────────────────────────────────
// GET /api/employees/:id/violation-counts
// Returns { violation_type: count } for the rolling 90-day window (non-negated).
app.get('/api/employees/:id/violation-counts', (req, res) => {
const rows = db.prepare(`
SELECT violation_type, COUNT(*) AS count
FROM violations
WHERE employee_id = ?
AND negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY violation_type
`).all(req.params.id);
const result = {};
for (const r of rows) result[r.violation_type] = r.count;
res.json(result);
});
// GET /api/employees/:id/violation-counts/alltime
// Returns { violation_type: { count, max_points_used } } across all time (non-negated).
app.get('/api/employees/:id/violation-counts/alltime', (req, res) => {
const rows = db.prepare(`
SELECT violation_type, COUNT(*) AS count, MAX(points) AS max_points_used
FROM violations
WHERE employee_id = ?
AND negated = 0
GROUP BY violation_type
`).all(req.params.id);
const result = {};
for (const r of rows) result[r.violation_type] = { count: r.count, max_points_used: r.max_points_used };
res.json(result);
});
// ── Audit log ────────────────────────────────────────────────────────────────
app.get('/api/audit', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
@@ -410,6 +647,100 @@ app.get('/api/audit', (req, res) => {
res.json(db.prepare(sql).all(...args));
});
// ── Custom Violation Types ────────────────────────────────────────────────────
// Persisted violation type definitions stored in violation_types table.
// type_key is auto-generated (custom_<slug>) to avoid collisions with hardcoded keys.
app.get('/api/violation-types', (req, res) => {
const rows = db.prepare('SELECT * FROM violation_types ORDER BY category ASC, name ASC').all();
res.json(rows.map(r => ({ ...r, fields: JSON.parse(r.fields) })));
});
app.post('/api/violation-types', (req, res) => {
const { name, category, chapter, description, min_points, max_points, fields, created_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
// Generate a unique type_key from the name, prefixed with 'custom_'
const base = 'custom_' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
let typeKey = base;
let suffix = 2;
while (db.prepare('SELECT id FROM violation_types WHERE type_key = ?').get(typeKey)) {
typeKey = `${base}_${suffix++}`;
}
try {
const result = db.prepare(`
INSERT INTO violation_types (type_key, name, category, chapter, description, min_points, max_points, fields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
typeKey,
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description'])
);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(result.lastInsertRowid);
audit('violation_type_created', 'violation_type', result.lastInsertRowid, created_by || null, { name: row.name, category: row.category });
res.status(201).json({ ...row, fields: JSON.parse(row.fields) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const { name, category, chapter, description, min_points, max_points, fields, updated_by } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
const minPts = parseInt(min_points) || 1;
const maxPts = parseInt(max_points) || minPts;
if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });
db.prepare(`
UPDATE violation_types
SET name=?, category=?, chapter=?, description=?, min_points=?, max_points=?, fields=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
name.trim(),
(category || 'Custom').trim(),
chapter || null,
description || null,
minPts,
maxPts,
JSON.stringify(fields && fields.length ? fields : ['description']),
id
);
const updated = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
audit('violation_type_updated', 'violation_type', id, updated_by || null, { name: updated.name, category: updated.category });
res.json({ ...updated, fields: JSON.parse(updated.fields) });
});
app.delete('/api/violation-types/:id', (req, res) => {
const id = parseInt(req.params.id);
const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'Violation type not found' });
const usage = db.prepare('SELECT COUNT(*) as count FROM violations WHERE violation_type = ?').get(row.type_key);
if (usage.count > 0) {
return res.status(409).json({ error: `Cannot delete: ${usage.count} violation(s) reference this type. Negate those violations first.` });
}
db.prepare('DELETE FROM violation_types WHERE id = ?').run(id);
audit('violation_type_deleted', 'violation_type', id, null, { name: row.name, type_key: row.type_key });
res.json({ ok: true });
});
// ── PDF endpoint ─────────────────────────────────────────────────────────────
app.get('/api/violations/:id/pdf', async (req, res) => {
try {
@@ -422,13 +753,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
if (!violation) return res.status(404).json({ error: 'Violation not found' });
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
.get(violation.employee_id) || { active_points: 0, violation_count: 0 };
const standing = standingFor(violation.employee_id);
const scoreForPdf = {
employee_id: violation.employee_id,
active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
violation_count: active.violation_count,
active_points: violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
violation_count: standing.violationCount,
};
const pdfBuffer = await generatePdf(violation, scoreForPdf);