Compare commits

135 Commits

Author SHA1 Message Date
da36edbba6 vscode 2026-03-11 22:58:53 -05:00
jason
d6585c01c6 Code review and fixes 2026-03-11 16:33:38 -05:00
eccb105340 Merge pull request 'feat: dashboard badge filters + Elite Standing 0–4 pts' (#43) from feature/dashboard-badge-filters into master
Reviewed-on: #43
2026-03-11 00:13:27 -05:00
b656c970f0 feat: stat card badges act as filters; Elite Standing = 0-4 pts 2026-03-11 00:11:42 -05:00
f8c0fcd441 Merge pull request 'fix: update TierWarning to use dark-mode-compatible colors' (#42) from fix/tier-warning-dark-mode into master
Reviewed-on: #42
2026-03-11 00:01:23 -05:00
91ba19d038 fix: update TierWarning to use dark-mode-compatible colors 2026-03-10 16:00:24 -05:00
b7753d492d Merge pull request 'feature/mobile-responsive' (#41) from feature/mobile-responsive into master
Reviewed-on: #41
2026-03-08 22:22:42 -05:00
e0cb66af46 Add comprehensive mobile-responsive documentation 2026-03-08 22:08:21 -05:00
0769a39491 Update Dashboard with responsive mobile/desktop layouts 2026-03-08 22:05:52 -05:00
15a2b89350 Add mobile-optimized Dashboard component with card layout 2026-03-08 22:05:04 -05:00
74492142a1 Update App.jsx with mobile-responsive navigation and layout 2026-03-08 22:04:05 -05:00
602f371d67 Add mobile-responsive CSS utility file 2026-03-08 22:02:10 -05:00
c86b070db3 Merge pull request 'feature/version-badge' (#40) from feature/version-badge into master
Reviewed-on: #40
2026-03-08 00:56:18 -06:00
f4ed8c49ce feat: fetch version.json on mount, show short SHA + commit link in footer 2026-03-08 00:55:44 -06:00
51bf176f96 feat: load version.json at startup, expose via /api/health 2026-03-08 00:54:58 -06:00
20be30318f feat: add local dev fallback version.json stub 2026-03-08 00:45:53 -06:00
b02026f8a9 feat: inject git SHA + build timestamp into version.json during docker build 2026-03-08 00:45:49 -06:00
87cf48e77e Update README.md 2026-03-08 00:42:48 -06:00
2348336b0f Merge pull request 'fix: remove default browser body margin causing white border' (#39) from fix/remove-body-margin into master
Reviewed-on: #39
2026-03-08 00:38:56 -06:00
995e607003 fix: remove default browser body margin causing white border 2026-03-08 00:38:38 -06:00
d613c92970 Merge pull request 'feat: add footer with copyright, live dev ticker, and Gitea repo link' (#38) from feature/footer-meta into master
Reviewed-on: #38
2026-03-08 00:36:14 -06:00
981fa3bea4 feat: add footer with copyright, live dev ticker, and Gitea repo link 2026-03-08 00:35:35 -06:00
dc45cb7d83 Merge pull request 'docs: update README with Phase 8 features, expanded roadmap with effort ratings' (#37) from feature/readme-update into master
Reviewed-on: #37
2026-03-08 00:33:39 -06:00
7326ffec6e docs: update README with Phase 8 features, expanded roadmap with effort ratings 2026-03-08 00:32:35 -06:00
5f0ae959ed Update client/src/App.jsx 2026-03-08 00:25:11 -06:00
917f5a24af Merge pull request 'feat: add footer with copyright, Gitea repo link, and live dev ticker' (#36) from feature/footer-meta into master
Reviewed-on: #36
2026-03-08 00:23:15 -06:00
9aa27d7598 Merge pull request 'feat: enhanced demo footer — copyright, Gitea link, live dev-time ticker' (#35) from feature/demo-footer-enhanced into master
Reviewed-on: #35
2026-03-08 00:21:31 -06:00
7e1164af13 feat: add footer with copyright, Gitea repo link, and live dev ticker
- © Jason Stedwell copyright with current year
- Gitea icon + link to https://git.alwisp.com/jason/cpas
- Running elapsed time ticker since first commit (2026-03-06T11:33:32)
  ticks every second: Xd HHh MMm SSs format
- App layout changed to flex column so footer pins to page bottom
- Footer styles isolated in `sf` object for clarity
2026-03-08 00:21:30 -06:00
232814db93 feat: enhance demo footer with copyright, Gitea link, and live dev-time ticker 2026-03-08 00:19:59 -06:00
87649b59d0 Merge pull request 'fix: add demo/ folder to Dockerfile so /demo route works in container' (#34) from feature/demo-dockerfile into master
Reviewed-on: #34
2026-03-08 00:12:11 -06:00
575c4c57fd fix: add demo/ folder to Dockerfile COPY so /demo route is served in container 2026-03-08 00:11:18 -06:00
7ef00796bd feat: serve demo/ folder as static route at /demo
Adds express.static middleware for the demo/ directory mounted at /demo,
placed before the SPA catch-all so /demo/index.html resolves to the
standalone stakeholder demo page instead of the React app.
2026-03-07 23:59:00 -06:00
9cbc06f57b Merge pull request 'feat: add stakeholder demo landing page with synthetic data' (#33) from feature/demo-landing into master
Reviewed-on: #33
2026-03-07 23:40:39 -06:00
76f972562b feat: add stakeholder demo landing page with synthetic data 2026-03-07 23:27:25 -06:00
b3882322b4 Merge pull request 'feature/department-dropdown' (#32) from feature/department-dropdown into master
Reviewed-on: #32
2026-03-07 23:16:07 -06:00
d8793000fc feat: replace department text input with preloaded select dropdown 2026-03-07 23:15:15 -06:00
0f31677631 feat: replace department text input with preloaded select dropdown 2026-03-07 23:14:40 -06:00
5d835e6b91 feat: add shared DEPARTMENTS constant 2026-03-07 23:13:26 -06:00
53dcce576a Merge pull request 'fix: score endpoint now returns total_violations and negated_count' (#30) from feature/ack-signature-and-toasts into master
Reviewed-on: #30
2026-03-07 22:02:34 -06:00
979e9724e0 fix: score endpoint now returns total_violations and negated_count
The /api/employees/:id/score endpoint previously only returned data from
the active_cpas_scores view (active_points + violation_count for the 90-day
window). The EmployeeModal score cards reference total_violations and
negated_count which were undefined, causing blank displays.

Now queries the violations table directly for all-time totals alongside
the rolling 90-day active data.
2026-03-07 22:01:35 -06:00
9c4c357cbe Merge pull request 'feature/ack-signature-and-toasts' (#29) from feature/ack-signature-and-toasts into master
Reviewed-on: #29
2026-03-07 21:47:24 -06:00
da602f69af docs: update ReadmeModal admin guide for acknowledgment signature and toast notifications
- Added acknowledgment signature workflow to "Logging a Violation" section (step 7)
- Added toast notification step to violation logging workflow (step 9)
- Updated Violation History section: amend now includes acknowledged-by/date fields
- Added PDF acknowledgment rendering note to Violation History
- Added "Toast Notifications" as standalone feature section
- Updated Amendable Fields to include acknowledged_by and acknowledged_date
- Updated Immutability Rules table with ack fields
- Moved acknowledgment signature and toast system to Shipped in roadmap
- Removed acknowledgment signature from Near-term (already shipped)
2026-03-07 21:46:30 -06:00
66f59dead3 docs: update README for acknowledgment signature field, toast notifications, and PDF logo refactor
- Moved acknowledgment signature field from Proposed to Completed (Phase 7)
- Added toast notification system to Completed (Phase 7)
- Updated Violation Form features with acknowledgment section and toasts
- Updated Employee Profile Modal features with toast notifications
- Added Toast Notification System as standalone feature section
- Updated PDF Generation section: logo loaded from disk, ack rendering
- Updated Amendable Fields table with acknowledged_by and acknowledged_date
- Updated Database Schema section with new violation columns
- Updated Project Structure tree with ToastProvider.jsx and template.js description
- Updated API reference for POST /api/violations to note new fields
2026-03-07 21:44:22 -06:00
ecd3810050 feat: add toast notifications to EmployeeModal for all actions
- Toast success/error on PDF download, negate, restore, hard delete
- Toast success on employee edit and violation amendment via modal callbacks
- Error details from API responses included in error toasts
2026-03-07 21:40:36 -06:00
114dbb1166 refactor: load logo from disk instead of hardcoded base64 + add acknowledgment signature rendering
- Reads mpm-logo.png from filesystem at startup, converts to base64 data URI dynamically
- Removes massive hardcoded LOGO_B64 constant (~5KB of source code eliminated)
- Falls back gracefully if logo file is not found (dev environments)
- Tries both dist/ and public/ paths for dev vs production compatibility
- Adds acknowledgment rendering: if acknowledged_by is set, shows filled name/date
  instead of blank signature lines, with green "Acknowledged" badge on section header
- Blank signature lines still shown when acknowledgment is not provided
2026-03-07 21:39:01 -06:00
b4edcdc945 feat: accept acknowledged_by/acknowledged_date in violation creation and amendment
- POST /api/violations now accepts acknowledged_by and acknowledged_date
- Both fields added to AMENDABLE_FIELDS whitelist for post-submission edits
- Acknowledgment data persisted to DB and passed through to PDF generation
2026-03-07 21:32:15 -06:00
8944cc80e0 feat: add auto-migration for acknowledged_by/acknowledged_date columns 2026-03-07 21:31:05 -06:00
8e06c9d576 feat: add acknowledged_by and acknowledged_date columns to violations schema 2026-03-07 21:30:47 -06:00
725dfa2963 feat: add acknowledgment signature fields + toast notifications to ViolationForm
- New "Employee Acknowledgment" section with acknowledged_by name and date
- Replaces blank signature line on PDF with recorded acknowledgment
- Toast notifications for submit success/error, PDF download, and validation warnings
- Inline status messages retained as fallback
2026-03-07 21:30:29 -06:00
c4dd658aa7 feat: wrap App with ToastProvider for global notifications 2026-03-07 21:29:05 -06:00
57358dfd21 feat: add Toast notification system
- ToastProvider context with useToast hook
- Supports success, error, info, and warning variants
- Auto-dismiss with configurable duration (default 4s)
- Slide-in animation with progress bar
- Stacks up to 5 toasts, oldest dismissed first
- Consistent with dark theme UI
2026-03-07 21:28:45 -06:00
915bca17fd Merge pull request 'roadmap' (#28) from roadmap into master
Reviewed-on: #28
2026-03-07 19:02:11 -06:00
0920bffc50 docs: restore full README with corrected local image name (cpas not cpas-tracker) 2026-03-07 19:00:02 -06:00
bfa46e93b6 docs: fix local quickstart image name to cpas (not cpas-tracker) 2026-03-07 18:58:28 -06:00
1bc2527c53 Merge pull request 'roadmap' (#27) from roadmap into master
Reviewed-on: #27
2026-03-07 18:56:42 -06:00
281825377f feat: ReadmeModal — admin usage guide, feature map, workflow reference, roadmap (no install content) 2026-03-07 18:39:01 -06:00
554b39672f docs: replace Unraid stub with verified working install settings from production server 2026-03-07 18:38:33 -06:00
6390e6077c Merge pull request 'roadmap' (#26) from roadmap into master
Reviewed-on: #26
2026-03-07 18:35:43 -06:00
84f5124850 docs: rewrite ReadmeModal as admin usage guide — feature map, workflow, tier system, roadmap 2026-03-07 18:33:54 -06:00
db34700996 docs: sync README — add in-app docs to features + completed roadmap phase 6 2026-03-07 18:33:50 -06:00
b5a588e752 docs: update README — add notes/expiration features, new endpoints, updated schema and roadmap 2026-03-07 18:31:14 -06:00
431f31b857 Merge pull request 'roadmap' (#25) from roadmap into master
Reviewed-on: #25
2026-03-07 09:53:15 -06:00
d4638783a4 feat: add Docs button to navbar — opens ReadmeModal slide-in panel 2026-03-07 09:52:16 -06:00
9d4d465755 feat: ReadmeModal — full README rendered in a themed slide-in panel 2026-03-07 09:51:57 -06:00
f8810e1ae5 Merge pull request 'roadmap' (#24) from roadmap into master
Reviewed-on: #24
2026-03-07 09:46:39 -06:00
57d0ac9755 feat: wire ExpirationTimeline and EmployeeNotes into EmployeeModal 2026-03-07 09:44:54 -06:00
328fc6f307 feat: EmployeeNotes component — inline free-text notes with quick-add HR flag tags 2026-03-07 09:43:54 -06:00
37efd596dd feat: ExpirationTimeline component — per-violation roll-off countdown with tier drop projection 2026-03-07 09:43:31 -06:00
b02464330b feat: add expiration timeline endpoint and notes field to employee endpoints 2026-03-07 09:42:59 -06:00
be2d1fa68e feat(db): add notes column to employees table 2026-03-07 09:41:50 -06:00
0a8b6e44d8 docs: update README — mark roadmap items complete, add new features, update schema and API reference 2026-03-07 09:40:34 -06:00
9b80afd54d Merge pull request 'roadmap' (#23) from roadmap into master
Reviewed-on: #23
2026-03-07 09:30:45 -06:00
970bc0efea feat: add Audit Log button to Dashboard toolbar 2026-03-07 09:26:33 -06:00
7ee76468c4 feat: wire EditEmployeeModal and AmendViolationModal into EmployeeModal 2026-03-07 09:25:49 -06:00
2525cce03e feat: AuditLog panel component — filterable, paginated audit trail 2026-03-07 09:24:49 -06:00
15d3f02884 feat: AmendViolationModal — edit non-scoring fields with full diff history 2026-03-07 09:24:13 -06:00
ee91a16506 feat: EditEmployeeModal — edit name/dept/supervisor and merge duplicates 2026-03-07 09:23:39 -06:00
5004c56915 feat: employee edit/merge, violation amendment, audit log endpoints 2026-03-07 09:23:04 -06:00
9b6f2353be feat(db): add violation_amendments and audit_log tables 2026-03-07 09:22:01 -06:00
5140dbbc25 Merge pull request 'fix: reduce PDF margins and remove redundant puppeteer footer' (#22) from claude into master
Reviewed-on: #22
2026-03-06 23:41:15 -06:00
bcf0e3f3d1 fix: reduce PDF margins and remove redundant puppeteer footer 2026-03-06 23:40:49 -06:00
2bc1740e02 Merge pull request 'feat: redesign PDF template - clean modern layout with inline logo' (#21) from claude into master
Reviewed-on: #21
2026-03-06 23:37:57 -06:00
3977c3652f feat: redesign PDF template - clean modern layout with inline logo 2026-03-06 23:34:45 -06:00
e19ef255ac Merge pull request 'fix: dark mode colors in ViolationHistory component' (#20) from claude into master
Reviewed-on: #20
2026-03-06 23:27:24 -06:00
f4f191518c fix: dark mode colors in ViolationHistory component 2026-03-06 23:26:39 -06:00
69272f71a3 docs: add roadmap section with completed features and proposed enhancements 2026-03-06 23:21:24 -06:00
a6447970ac docs: update README to reflect current codebase (phases 1-4 complete) 2026-03-06 23:18:28 -06:00
e0170d71f5 Merge pull request 'Upload files to "/"' (#19) from p4-hotfixes into master
Reviewed-on: #19
2026-03-06 18:00:01 -06:00
4bb09997ee Upload files to "/" 2026-03-06 17:59:48 -06:00
52a57f21b0 Merge pull request 'Upload files to "client/src/components"' (#18) from p4-hotfixes into master
Reviewed-on: #18
2026-03-06 17:43:31 -06:00
3cc62c2746 Upload files to "client/src/components" 2026-03-06 17:43:14 -06:00
47e14ae23b Merge pull request 'Upload files to "client/src/components"' (#17) from p4-hotfixes into master
Reviewed-on: #17
2026-03-06 17:20:42 -06:00
a6d4885a53 Upload files to "client/src/components" 2026-03-06 17:19:57 -06:00
f4869b42b4 Merge pull request 'p4-hotfixes' (#16) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/16
2026-03-06 15:44:34 -06:00
98fe9d4a79 Upload files to "client/src/components" 2026-03-06 15:44:15 -06:00
f52af27114 Upload files to "client/src/components" 2026-03-06 15:38:20 -06:00
7ff066011d Merge pull request 'Upload files to "client/src/components"' (#15) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/15
2026-03-06 15:26:06 -06:00
35b4ded10c Upload files to "client/src/components" 2026-03-06 15:25:48 -06:00
5ef61f6590 Merge pull request 'p4-hotfixes' (#14) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/14
2026-03-06 15:01:04 -06:00
cb8c56adfa Upload files to "pdf" 2026-03-06 15:00:27 -06:00
accb24a286 Upload files to "db" 2026-03-06 14:59:55 -06:00
fdfa0bcf2f Upload files to "/" 2026-03-06 14:42:12 -06:00
2ac330904d Merge pull request 'p4-hotfixes' (#13) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/13
2026-03-06 14:38:57 -06:00
60e9da488c Upload files to "pdf" 2026-03-06 14:38:43 -06:00
1a09efbfe5 Upload files to "client/src/components" 2026-03-06 14:38:29 -06:00
de07e77e07 Merge pull request 'Update client/src/components/ViolationForm.jsx' (#12) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/12
2026-03-06 14:23:44 -06:00
722f404269 Update client/src/components/ViolationForm.jsx 2026-03-06 14:23:19 -06:00
7db080bd2a Merge pull request 'Upload files to "client/src/components"' (#11) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/11
2026-03-06 14:21:26 -06:00
571c8f2838 Upload files to "client/src/components" 2026-03-06 14:20:57 -06:00
eb07832cc7 Upload files to "client/public/static" 2026-03-06 14:15:05 -06:00
6305cedb14 Delete client/public 2026-03-06 14:14:27 -06:00
f006c015b0 Add client/public 2026-03-06 14:14:11 -06:00
ecc9f3b9f6 Merge pull request 'p4-hotfixes' (#10) from p4-hotfixes into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/10
2026-03-06 14:13:15 -06:00
066f95cc88 Upload files to "pdf" 2026-03-06 14:12:00 -06:00
2383e3cc94 Upload files to "client/src/components" 2026-03-06 14:11:46 -06:00
8bbfd90f48 Upload files to "client/src" 2026-03-06 14:11:36 -06:00
590aae5cca Upload files to "/" 2026-03-06 14:11:19 -06:00
39738ed3e1 Merge pull request 'p4' (#9) from p4 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/9
2026-03-06 13:57:51 -06:00
821192e879 Update db/database.js 2026-03-06 13:56:36 -06:00
610d72ab66 Upload files to "pdf" 2026-03-06 13:50:42 -06:00
5bcd6d07dc Upload files to "client/src/components" 2026-03-06 13:50:25 -06:00
1533d9aeab Merge pull request 'Hotfixes' (#7) from p4 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/7
2026-03-06 13:46:45 -06:00
1991c105b7 Hotfixes 2026-03-06 13:46:12 -06:00
2a42e335ca Merge pull request 'Phase 4' (#6) from p4 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/6
2026-03-06 12:54:52 -06:00
333cad41d7 Phase 4 2026-03-06 12:53:40 -06:00
e8962f058c Merge pull request 'Upload files to "pdf"' (#5) from p3 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/5
2026-03-06 12:36:59 -06:00
3279a5c60e Upload files to "pdf" 2026-03-06 12:36:43 -06:00
30c7545cd0 Merge pull request 'Upload files to "pdf"' (#4) from p3 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/4
2026-03-06 12:28:11 -06:00
740f328beb Upload files to "pdf" 2026-03-06 12:27:55 -06:00
b6a0950565 Merge pull request 'Upload files to "/"' (#3) from p3 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/3
2026-03-06 12:24:40 -06:00
050bd55bc1 Upload files to "/" 2026-03-06 12:24:17 -06:00
161a1fec3d Merge pull request 'Phase 3' (#2) from p3 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/2
2026-03-06 12:20:51 -06:00
62b142d4a3 Phase 3 2026-03-06 12:19:55 -06:00
bc9e54621b Merge pull request 'Phase 2' (#1) from p2 into master
Reviewed-on: http://10.2.0.2:3000/jason/cpas/pulls/1
2026-03-06 12:05:17 -06:00
13f6c9d164 Phase 2 2026-03-06 12:02:52 -06:00
33 changed files with 6001 additions and 353 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"giteaActions.baseUrl": "https://git.alwisp.com",
"giteaActions.discovery.mode": "allAccessible"
}

View File

@@ -1,62 +1,39 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1: Builder
# Installs ALL dependencies and compiles the React frontend inside Docker.
# Nothing needs to be installed on the host machine except Docker itself.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /build
# Install backend deps
COPY package.json ./
RUN npm install
# Install frontend deps and build React app
COPY client/package.json ./client/
RUN cd client && npm install
COPY client/ ./client/
RUN cd client && npm run build
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2: Production image
# Copies only what's needed to run — no dev tools, no node_modules for client.
# Final image is lean (~180MB) and ready to run with zero host setup.
# ─────────────────────────────────────────────────────────────────────────────
# ── 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
# Chromium + deps for Phase 3 Puppeteer PDF generation
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV NODE_ENV=production
ENV PORT=3001
ENV DB_PATH=/data/cpas.db
WORKDIR /app
# Copy backend deps from builder
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/client/dist ./client/dist
# Copy backend source
COPY server.js ./
COPY db/ ./db/
COPY server.js ./
COPY package.json ./
# Ensure data directory exists (will be overridden by volume mount)
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 wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "server.js"]

314
MOBILE_RESPONSIVE.md Normal file
View File

@@ -0,0 +1,314 @@
# Mobile-Responsive Implementation Guide
## Overview
This document describes the mobile-responsive updates implemented for the CPAS Tracker application. The design targets **standard phones (375px+ width)** with graceful degradation for smaller devices.
## Key Changes
### 1. **Responsive Utility Stylesheet** (`client/src/styles/mobile.css`)
A centralized CSS file providing:
- Media query breakpoints (768px, 480px)
- Touch-friendly tap targets (min 44px height)
- iOS input zoom prevention (16px font size)
- Utility classes for mobile layouts
- Card-based layout helpers
- Horizontal scroll containers
**Utility Classes:**
- `.hide-mobile` - Hide on screens ≤768px
- `.hide-tablet` - Hide on screens ≤1024px
- `.mobile-full-width` - Full width on mobile
- `.mobile-stack` - Stack flex items vertically
- `.mobile-scroll-x` - Enable horizontal scrolling
- `.mobile-card` - Card layout container
- `.mobile-sticky-top` - Sticky header positioning
### 2. **App Navigation** (`client/src/App.jsx`)
**Desktop Behavior:**
- Horizontal navigation bar
- Logo left, tabs center, docs button right
- Full tab labels displayed
**Mobile Behavior (768px):**
- Logo centered with full width
- Tabs stacked horizontally below logo
- Docs button positioned absolutely top-right
- Shortened tab labels ("📊 Dashboard" → "📊")
- Flexible padding (40px → 16px)
**Features:**
- `useMediaQuery()` hook for responsive detection
- Dynamic style injection via `<style>` tag
- Separate mobile CSS classes for targeted overrides
### 3. **Dashboard Layout** (`client/src/components/Dashboard.jsx`)
**Desktop View:**
- Traditional HTML table layout
- 7 columns (Index, Employee, Dept, Supervisor, Tier, Points, Violations)
- Horizontal scrolling for overflow
**Mobile View (768px):**
- Switches to card-based layout (DashboardMobile component)
- Each employee = one card with vertical data rows
- Touch-optimized tap targets
- Improved readability with larger fonts
**Mobile Stat Cards:**
- 2 columns on phones (480px+)
- 1 column on small phones (<480px)
- Reduced font sizes (28px → 24px)
- Compact padding
**Toolbar Adjustments:**
- Search input: 260px → 100% width
- Buttons stack vertically
- Full-width button styling
### 4. **Mobile Dashboard Component** (`client/src/components/DashboardMobile.jsx`)
A dedicated mobile-optimized employee card component:
**Card Structure:**
```
+--------------------------------+
| Employee Name [Button] |
| [At Risk Badge if applicable] |
|--------------------------------|
| Tier / Standing: [Badge] |
| Active Points: [Large #] |
| 90-Day Violations: [#] |
| Department: [Name] |
| Supervisor: [Name] |
+--------------------------------+
```
**Visual Features:**
- At-risk employees: Gold border + dark gold background
- Touch-friendly employee name buttons
- Color-coded point displays matching tier colors
- Compact spacing (12px margins)
- Subtle shadows for depth
### 5. **Responsive Breakpoints**
| Breakpoint | Target Devices | Layout Changes |
|------------|----------------|----------------|
| **1024px** | Tablets & below | Reduced padding, simplified nav |
| **768px** | Phones (landscape) | Card layouts, stacked navigation |
| **480px** | Small phones | Single-column stats, minimal spacing |
| **375px** | iPhone SE/6/7/8 | Optimized for minimum supported width |
### 6. **Touch Optimization**
**Tap Target Sizes:**
- All buttons: 44px minimum height (iOS/Android guidelines)
- Form inputs: 44px minimum height
- Navigation tabs: 44px touch area
**Typography:**
- Form inputs: 16px font size (prevents iOS zoom-in on focus)
- Readable body text: 13-14px
- Headers scale down appropriately
**Scrolling:**
- `-webkit-overflow-scrolling: touch` for smooth momentum scrolling
- Horizontal scroll on tables (desktop fallback)
- Vertical card scrolling on mobile
## Implementation Details
### Media Query Hook
```javascript
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;
}
```
**Usage:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
### Conditional Rendering Pattern
```javascript
{isMobile ? (
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
) : (
<table style={s.table}>
{/* Desktop table layout */}
</table>
)}
```
### Dynamic Style Injection
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.dashboard-wrap {
padding: 16px !important;
}
}
`;
return (
<>
<style>{mobileStyles}</style>
{/* Component JSX */}
</>
);
```
## Testing Checklist
### Desktop (>768px)
- [ ] Navigation displays horizontally
- [ ] Dashboard shows full table
- [ ] All columns visible
- [ ] Docs button on right side
- [ ] Full tab labels visible
### Tablet (768px - 1024px)
- [ ] Reduced padding maintains readability
- [ ] Stats cards wrap to 2-3 columns
- [ ] Table scrolls horizontally if needed
### Mobile Portrait (375px - 768px)
- [ ] Logo centered, tabs stacked
- [ ] Dashboard shows card layout
- [ ] Search input full width
- [ ] Buttons stack vertically
- [ ] Employee cards display all data
- [ ] Tap targets ≥44px
- [ ] No horizontal scroll required
### Small Mobile (<480px)
- [ ] Stat cards single column
- [ ] Text remains readable
- [ ] No layout breakage
- [ ] Footer wraps properly
### iOS-Specific
- [ ] Input focus doesn't zoom page (16px font)
- [ ] Smooth momentum scrolling
- [ ] Tap highlights work correctly
### Android-Specific
- [ ] Touch feedback visible
- [ ] Back button behavior correct
- [ ] Keyboard doesn't break layout
## Browser Support
- **Chrome/Edge:** 88+
- **Firefox:** 85+
- **Safari:** 14+
- **iOS Safari:** 14+
- **Chrome Android:** 88+
## Performance Considerations
1. **Media query hook** re-renders only on breakpoint changes, not continuous resize
2. **Card layout** renders fewer DOM elements than table on mobile
3. **CSS injection** happens once per component mount
4. **No external CSS libraries** (zero KB bundle increase)
## Future Enhancements
### Phase 2 (Optional)
- [ ] ViolationForm mobile optimization with multi-step wizard
- [ ] Modal responsive sizing and animations
- [ ] Swipe gestures for employee cards
- [ ] Pull-to-refresh on mobile
- [ ] Offline support with service workers
### Phase 3 (Advanced)
- [ ] Progressive Web App (PWA) capabilities
- [ ] Native app shell with Capacitor
- [ ] Biometric authentication
- [ ] Push notifications
## File Structure
```
client/src/
├── App.jsx # Updated with mobile nav
├── components/
│ ├── Dashboard.jsx # Responsive table/card switch
│ ├── DashboardMobile.jsx # Mobile card layout (NEW)
│ └── ... # Other components
└── styles/
└── mobile.css # Responsive utilities (NEW)
```
## Maintenance Notes
### Adding New Components
When creating new components, follow this pattern:
1. **Import mobile.css utility classes:**
```javascript
import '../styles/mobile.css';
```
2. **Use media query hook:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
3. **Provide mobile-specific styles:**
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.my-component { /* mobile overrides */ }
}
`;
```
4. **Test on real devices** (Chrome DevTools is insufficient for touch testing)
### Debugging Tips
- Use Chrome DevTools Device Mode (Ctrl+Shift+M)
- Test on actual devices when possible
- Check console for media query match state
- Verify tap target sizes with Chrome Lighthouse audit
- Test keyboard behavior on Android
## Deployment
1. Merge `feature/mobile-responsive` into `master`
2. Rebuild client bundle: `cd client && npm run build`
3. Restart server
4. Clear browser cache (Ctrl+Shift+R)
5. Test on production URL with mobile devices
## Support
For issues or questions about mobile-responsive implementation:
- Check browser console for errors
- Verify `mobile.css` is loaded
- Test with different screen sizes
- Review media query breakpoints
---
**Branch:** `feature/mobile-responsive`
**Target Width:** 375px+ (standard phones)
**Last Updated:** March 8, 2026
**Maintainer:** Jason Stedwell

390
README.md
View File

@@ -1,11 +1,15 @@
# CPAS Violation Tracker
Single-container Dockerized web app for CPAS violation documentation.
Built with React + Vite (frontend), Node.js + Express (backend), SQLite (database).
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 — happens inside Docker.
Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker.
---
@@ -13,61 +17,381 @@ Everything else — Node.js, npm, React build — happens inside Docker.
```bash
# 1. Build the image (installs all deps + compiles React inside Docker)
docker build -t cpas-tracker .
docker build -t cpas .
# 2. Run it
docker run -d --name cpas-tracker \
docker run -d --name cpas \
-p 3001:3001 \
-v cpas-data:/data \
cpas-tracker
cpas
# 3. Open
# http://localhost:3001
```
## Export for Unraid
```bash
docker save cpas-tracker | gzip > cpas-tracker.tar.gz
```
Then follow README_UNRAID_INSTALL.md.
## Update After Code Changes
```bash
docker build -t cpas-tracker .
docker stop cpas-tracker && docker rm cpas-tracker
docker run -d --name cpas-tracker -p 3001:3001 -v cpas-data:/data cpas-tracker
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-violation-tracker/
├── Dockerfile # Multi-stage: builds React + runs Express
cpas/
├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium
├── .dockerignore
├── package.json # Backend (Express) deps
├── server.js # API + static file server
├── package.json # Backend (Express) deps
├── server.js # API + static file server
├── db/
│ ├── schema.sql # Tables + 90-day score view
│ └── database.js # SQLite connection
── client/ # React frontend (Vite)
│ ├── 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
├── App.jsx # Root app + AppFooter (copyright, dev ticker, Gitea link)
├── data/
│ └── violations.js # All CPAS violation definitions
│ └── violations.js # All CPAS violation definitions + groups
├── hooks/
│ └── useEmployeeIntelligence.js # Score + history hook
└── components/
── ViolationForm.jsx
── 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
```
## Phases
- [x] Phase 1 — Container scaffold, SQLite schema, base React form
- [ ] Phase 2 — Employee history, prior violation highlighting
- [ ] Phase 3 — Puppeteer PDF generation
- [ ] Phase 4 — Dashboard, CPAS scores, tier warnings
- [ ] Phase 5 — Recidivist point auto-suggest
---
## 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.*

View File

@@ -4,6 +4,11 @@
<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,5 @@
{
"sha": "dev",
"shortSha": "dev",
"buildTime": null
}

View File

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

View File

@@ -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>
);
}

View File

@@ -0,0 +1,204 @@
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;
// 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;
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;
// 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);
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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,351 @@
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;
}
// 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(() => {
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 (
<>
{/* 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">
<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)} />}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,442 @@
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/${employeeId}`),
axios.get(`/api/employees/${employeeId}/score`),
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
])
.then(([empRes, scoreRes, violRes]) => {
setEmployee(empRes.data || 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>
);
}

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import axios from 'axios';
import { useToast } from './ToastProvider';
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 [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);
}
};
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
/>
{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'}
</button>
<button style={s.cancelBtn} onClick={handleCancel} disabled={saving}>
Cancel
</button>
{saving && <span style={s.saving}>Saving</span>}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
// 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' },
{ 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>
);
}

View File

@@ -0,0 +1,136 @@
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,
});
};
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget && onCancel) onCancel();
};
return (
<div style={s.overlay} onClick={handleOverlayClick}>
<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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -0,0 +1,9 @@
export const DEPARTMENTS = [
'Administrative',
'Business Development',
'Design and Content',
'Executive',
'Implementation and Support',
'Operations',
'Production',
];

View File

@@ -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 };
}

View File

@@ -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;
}
}

View File

@@ -2,18 +2,75 @@ const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DB_PATH = process.env.DB_PATH || '/data/cpas.db';
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
const 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 dbDir = path.dirname(DB_PATH);
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
const db = new Database(DB_PATH);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
console.log(`[DB] Connected: ${DB_PATH}`);
// ── 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
)`);
// 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;

View File

@@ -1,36 +1,49 @@
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
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,
employee_id INTEGER NOT NULL REFERENCES employees(id),
violation_type TEXT NOT NULL,
violation_name TEXT NOT NULL,
category TEXT NOT NULL,
points INTEGER NOT NULL,
incident_date DATE NOT NULL,
incident_time TEXT,
location TEXT,
violation_id INTEGER NOT NULL REFERENCES violations(id) ON DELETE CASCADE,
resolution_type TEXT NOT NULL,
details TEXT,
submitted_by TEXT,
witness_name 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
e.id AS employee_id,
e.name AS employee_name,
e.department,
COALESCE(SUM(v.points), 0) AS active_points,
COUNT(v.id) AS violation_count
FROM employees e
LEFT JOIN violations v
ON v.employee_id = e.id
AND v.incident_date >= DATE('now', '-90 days')
GROUP BY e.id;
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;

893
demo/index.html Normal file
View File

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

View File

@@ -15,7 +15,8 @@
"dependencies": {
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"express": "^4.18.3"
"express": "^4.18.3",
"puppeteer-core": "^22.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"

47
pdf/generator.js Executable file
View File

@@ -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;

385
pdf/template.js Executable file
View File

@@ -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;

516
server.js
View File

@@ -1,7 +1,8 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db/database');
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;
@@ -10,56 +11,495 @@ app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'client', 'dist')));
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
// TODO [CRITICAL #1]: No authentication on any route. Add an auth middleware
// (e.g. express-session + password, or JWT) before all /api/* routes.
// Anyone on the network can currently create, delete, or negate violations.
// ── 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.get('/api/employees', (req, res) => {
const rows = db.prepare('SELECT id, name, department, supervisor 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' });
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 { 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);
}
const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)').run(name, department || null, supervisor || null);
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
// 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 (?, ?, ?)')
.run(name, department || null, supervisor || null);
audit('employee_created', 'employee', result.lastInsertRowid, null, { name });
res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
});
app.get('/api/violations/employee/:employeeId', (req, res) => {
const rows = db.prepare('SELECT * FROM violations WHERE employee_id = ? ORDER BY incident_date DESC').all(req.params.employeeId);
res.json(rows);
// ── 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 });
});
app.get('/api/employees/:employeeId/score', (req, res) => {
const row = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(req.params.employeeId);
res.json(row || { active_points: 0, violation_count: 0 });
// ── 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 } = req.body;
if (!employee_id || !violation_type || !points || !incident_date) {
return res.status(400).json({ error: 'Missing required fields: employee_id, violation_type, points, incident_date' });
const {
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'];
// 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);
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(() => {
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);
}
}
const result = db.prepare(`
INSERT INTO violations (employee_id, violation_type, violation_name, category, points, incident_date, incident_time, location, details, submitted_by, witness_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(employee_id, violation_type, violation_name || violation_type, category || 'General', points, incident_date, incident_time || null, location || null, details || null, submitted_by || null, witness_name || null);
res.status(201).json({ id: result.lastInsertRowid });
});
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);
});
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html'));
// ── 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 });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`[CPAS] Server running on port ${PORT}`);
// ── 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 });
});
// ── 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);
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));
});
// ── 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}`));