diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c1e5d28 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(node --check server.js)", + "Bash(env)", + "Bash(findstr /I OBSIDIAN)", + "Bash(set)", + "Bash(xargs grep -l -i \"obsidian\")" + ] + } +} diff --git a/.claude/worktrees/musing-bell/.dockerignore b/.claude/worktrees/musing-bell/.dockerignore new file mode 100644 index 0000000..7867acd --- /dev/null +++ b/.claude/worktrees/musing-bell/.dockerignore @@ -0,0 +1,9 @@ +node_modules +client/node_modules +client/dist +*.tar.gz +*.zip +.git +.gitignore +*.md +data/ diff --git a/.claude/worktrees/musing-bell/AGENTS.md b/.claude/worktrees/musing-bell/AGENTS.md new file mode 100644 index 0000000..b7fd96a --- /dev/null +++ b/.claude/worktrees/musing-bell/AGENTS.md @@ -0,0 +1,294 @@ +# AGENTS.md — CPAS Violation Tracker + +Developer and AI agent guidance for working on this codebase. Read this before making changes. + +--- + +## Project Purpose + +CPAS (Corrective & Progressive Accountability System) is an internal HR tool for documenting employee violations, managing disciplinary tier escalation via a rolling 90-day point system, and producing auditable PDF records. It is a single-container Docker app deployed on a trusted internal network. + +**This is a compliance tool.** Data integrity, auditability, and reversibility are first-class concerns. Every architectural decision below exists for a reason. + +--- + +## Stack at a Glance + +| Layer | Tech | +|---|---| +| Frontend | React 18 + Vite (SPA, served statically by Express) | +| Backend | Node.js + Express (REST API, `server.js`) | +| Database | SQLite via `better-sqlite3` (synchronous, WAL mode, FK enforcement) | +| PDF | Puppeteer + system Chromium (Alpine-bundled in Docker) | +| Styling | Inline React style objects; `client/src/styles/mobile.css` for breakpoints only | +| Deploy | Docker multi-stage build (Alpine); single container + volume mount at `/data` | + +--- + +## Repository Layout + +``` +cpas/ +├── Dockerfile # Multi-stage: builder (Node+React) → production (Alpine+Chromium) +├── server.js # All API routes + audit helper; single Express entry point +├── db/ +│ ├── schema.sql # Base table + view definitions (CREATE TABLE IF NOT EXISTS) +│ └── database.js # DB connection, WAL/FK pragmas, auto-migrations on startup +├── pdf/ +│ ├── generator.js # Puppeteer launcher; --no-sandbox for Docker +│ └── template.js # HTML template builder; loads logo from disk +├── demo/ # Static stakeholder demo page served at /demo +│ └── index.html # Synthetic data, no live API calls; registered before SPA catch-all +├── client/ +│ ├── vite.config.js +│ ├── src/ +│ │ ├── App.jsx # Root component + AppFooter +│ │ ├── main.jsx # React DOM mount +│ │ ├── data/ +│ │ │ ├── violations.js # Canonical violation type registry (type key → metadata) +│ │ │ └── departments.js # DEPARTMENTS constant; single source of truth +│ │ ├── hooks/ +│ │ │ └── useEmployeeIntelligence.js # Score + history fetch hook +│ │ ├── components/ # One file per component; no barrel index +│ │ └── styles/ +│ │ └── mobile.css # Media query overrides only; all other styles are inline +└── README.md / README_UNRAID_INSTALL.md +``` + +--- + +## Data Model & Compliance Rules + +### Tables + +| Table | Purpose | +|---|---| +| `employees` | id, name, department, supervisor, notes | +| `violations` | Full incident record; contains immutable scoring fields | +| `violation_resolutions` | Soft-delete records (resolution type, reason, resolver) | +| `violation_amendments` | Field-level diff per amendment (old → new, changed_by, timestamp) | +| `audit_log` | Append-only write action log; never delete from this table | +| `active_cpas_scores` | VIEW: SUM(points) for negated=0 AND incident_date >= 90 days | + +### Immutable Fields (DO NOT allow amendment of these) + +The following fields on `violations` are locked after submission. They are the basis for tier calculation and PDF accuracy. **Never expose them to amendment endpoints:** + +- `points` +- `violation_type` +- `violation_name` +- `category` +- `incident_date` +- `prior_active_points` (snapshot at insert time) +- `prior_tier_label` + +Amendable fields (non-scoring): `location`, `details`, `witness_name`, `acknowledged_by`, `acknowledged_date` + +### Soft-Delete Pattern + +Violations are **never hard-deleted** in normal workflow. Use the `negated` flag + `violation_resolutions` record. Hard delete is reserved for confirmed data-entry errors and requires explicit user confirmation in the UI. + +### Prior-Points Snapshot + +Every `INSERT` into `violations` must compute and store `prior_active_points` (the employee's current active score before this violation is added). This snapshot ensures PDFs always reflect the accurate historical tier state regardless of subsequent negate/restore actions. + +### Audit Log + +Every write action (employee created/edited/merged, violation logged/amended/negated/restored/deleted) must call the `audit()` helper in `server.js`. Never skip audit calls on write routes. The audit log is append-only — no UPDATE or DELETE against `audit_log`. + +--- + +## CPAS Tier System + +These thresholds are the authoritative values. Any feature touching tiers must use them. + +| Points | Tier | Label | +|---|---|---| +| 0–4 | 0-1 | Elite Standing | +| 5–9 | 1 | Realignment | +| 10–14 | 2 | Administrative Lockdown | +| 15–19 | 3 | Verification | +| 20–24 | 4 | Risk Mitigation | +| 25–29 | 5 | Final Decision | +| 30+ | 6 | Separation | + +The canonical tier logic lives in `client/src/components/CpasBadge.jsx` (`TIERS` array, `getTier()`, `getNextTier()`). Do not duplicate this logic elsewhere — import from `CpasBadge`. + +The 90-day rolling window is computed by the `active_cpas_scores` view. This view is **dropped and recreated** in `database.js` on every startup to ensure it always reflects the correct `negated=0` filter. + +--- + +## Violation Type Registry + +All violation types are defined in `client/src/data/violations.js` as `violationData`. Each entry includes: + +```js +{ + name: string, // Display name + category: string, // Grouping for UI display + minPoints: number, // Slider minimum + maxPoints: number, // Slider maximum (min === max means fixed, no slider) + chapter: string, // Policy chapter reference + fields: string[], // Which context fields to show ('time', 'minutes', 'amount', 'location', 'description') + description: string, // Plain-language definition shown in UI +} +``` + +To add a new violation type: add an entry to `violationData` with a unique camelCase key. Do not add new categories without confirming with the project owner — categories appear in UI groupings. + +--- + +## Coding Standards + +### Backend (`server.js`) + +- Use `better-sqlite3` synchronous API. No async DB calls. This is intentional — it simplifies route handlers and matches Express's sync error handling. +- All prepared statements use positional `?` parameters. Never interpolate user input into SQL strings. +- Every POST/PUT/PATCH/DELETE route must: + 1. Validate required inputs and return `400` with a descriptive `{ error: '...' }` body on failure. + 2. Call `audit()` on success. + 3. Return `{ error: '...' }` (not HTML) on all error paths. +- Group routes by resource (Employees, Violations, Dashboard, Audit). Match the existing comment banner style: `// ── Resource Name ───`. +- Do not add authentication middleware. This runs on a trusted internal network by design. + +### Frontend (React) + +- **Styling**: Use inline style objects defined as a `const s = { ... }` block at the top of each component file. Do not add CSS classes or CSS modules — except for responsive breakpoints which go in `mobile.css`. +- **Data constants**: Import violation types from `../data/violations`, departments from `../data/departments`, tier logic from `./CpasBadge`. Do not hardcode these values in components. +- **Toasts**: Use `useToast()` from `ToastProvider` for all user-facing feedback. Do not use `alert()` or `console.log` for user messages. +- **HTTP**: Use `axios` (already imported in form/modal components). Do not introduce `fetch` unless there is a compelling reason — keep it consistent. +- **State**: Prefer local `useState` over lifting state unless data is needed by multiple unrelated components. The only global context is `ToastProvider`. +- **Mobile**: Test layout at 768px breakpoint. Use the `isMobile` media query pattern already in `Dashboard.jsx` / `DashboardMobile.jsx`. Add breakpoint rules to `mobile.css`, not inline styles. +- **Component files**: One component per file. Name the file to match the export. No barrel `index.js` files. + +### Database Migrations + +New columns are added via the auto-migration pattern in `database.js`. Do not modify `schema.sql` for columns that already exist in production. Instead: + +```js +// Example: adding a new column to violations +const cols = db.prepare('PRAGMA table_info(violations)').all().map(c => c.name); +if (!cols.includes('new_column')) db.exec("ALTER TABLE violations ADD COLUMN new_column TEXT"); +``` + +Add a comment describing the feature the column enables. `schema.sql` is only for base tables — use it only for brand-new tables. + +--- + +## Schema Changes: Decision Checklist + +Before adding a column or table, answer: + +1. **Does it affect scoring?** If yes, it must be immutable after insert and included in `prior_active_points` computation logic. +2. **Does it need audit trail?** If it tracks a change to an existing record, add a corresponding entry pattern to `violation_amendments` or `audit_log`. +3. **Is it soft-deletable?** Prefer `negated`/flag patterns over hard deletes for anything HR might need to reverse. +4. **Does it appear on PDFs?** Update `pdf/template.js` to reflect it. Test PDF output after schema changes. +5. **Does `active_cpas_scores` view need updating?** If the new column affects point calculations, update the view recreation block in `database.js`. + +--- + +## PDF Generation + +- PDFs are generated on-demand via `GET /api/violations/:id/pdf`. No pre-caching. +- Template is built in `pdf/template.js`. It receives the full violation + employee record. Logo is loaded from disk at startup and embedded as base64. +- Puppeteer launches with `--no-sandbox --disable-setuid-sandbox` (required for Docker; safe in this deployment context). +- Acknowledgment rendering: if `acknowledged_by` is set, show name + date in signature block. If not, render blank wet-ink signature lines. +- After any schema change that adds user-visible fields, update the template to include the new field where appropriate. + +--- + +## Development Workflow + +### Local Development (without Docker) + +```bash +# Terminal 1 — backend +npm install +node server.js # Serves API on :3001 and client/dist statically + +# Terminal 2 — frontend (hot reload) +cd client +npm install +npm run dev # Vite dev server on :5173 (proxy to :3001 configured in vite.config.js) +``` + +### Build & Deploy + +```bash +# Build Docker image (compiles React inside container) +docker build -t cpas . + +# Run (local) +docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas + +# Unraid: build → save → transfer → load → run with --pids-limit 2048 +# See README_UNRAID_INSTALL.md for full Unraid instructions +``` + +**Unraid PID limit is critical.** Chromium spawns many child processes for PDF generation. Always include `--pids-limit 2048` on Unraid containers or PDF generation will fail silently. + +### Health Check + +`GET /api/health` returns `{ status: 'ok', timestamp, version }`. The `version` field is populated by the Dockerfile at build time from git commit SHA. In local dev it returns `{ sha: 'dev' }` — this is expected. + +--- + +## Forward-Thinking Development Guidelines + +### Adding New Features + +- **Score-affecting logic belongs in SQL**, not JavaScript. The `active_cpas_scores` view is the single source of truth for point totals. If you need a new score variant (e.g., 30-day window, category-filtered), add a new SQL view — don't compute it in a route handler. +- **New violation fields**: Add to `schema.sql` for fresh installs AND to the migration block in `database.js` for existing databases. Both are required. +- **Reporting features**: Future aggregate queries should join against `active_cpas_scores` view and `audit_log` rather than re-implementing point logic. Structure new API endpoints under `/api/reports/` namespace. +- **Notifications/alerts**: Any future alerting feature (email, Slack) should read from `audit_log` or query `active_cpas_scores` — do not add side effects directly into violation insert routes. +- **Authentication**: If auth is ever added, implement it as Express middleware applied globally before all `/api` routes. Do not add per-route auth checks. Session data (user identity) should flow into `performed_by` fields on audit and amendment records. +- **Multi-tenant / multi-site**: The schema is single-tenant. If site isolation is ever needed, add a `site_id` foreign key to `employees` and `violations` as a migration column, then scope all queries with a `WHERE site_id = ?` clause. + +### What NOT to Do + +- Do not compute active CPAS scores in JavaScript by summing violations client-side. Always fetch from the `active_cpas_scores` view. +- Do not modify `prior_active_points` after a violation is inserted. It is a historical snapshot, not a live value. +- Do not add columns to `audit_log`. It is append-only with a fixed schema. +- Do not add a framework or ORM. Raw SQL with prepared statements is intentional — it keeps the query behavior explicit and the dependency surface small. +- Do not add a build step beyond `vite build`. The backend is plain CommonJS `require()`; do not transpile it. +- Do not use `alert()`, `console.log` for user messages, or `document.querySelector` inside React components. + +--- + +## Documentation Standards + +### Code Comments + +- Comment **why**, not **what**. If the reason for a decision is not obvious from the code, explain it. +- Use the existing banner style for section groupings in `server.js`: + ```js + // ── Section Name ───────────────────────────────────────────────────────────── + ``` +- Mark non-obvious schema columns with inline SQL comments (see `schema.sql` for examples). +- When adding a migration block, include a comment naming the feature it enables. + +### In-App Documentation + +The `ReadmeModal.jsx` component renders an admin reference panel accessible via the `? Docs` button. When adding a significant new feature: +- Add it to the feature map section of the docs modal. +- Update the tier system table if thresholds change. +- Move completed roadmap items from the "Proposed" section to the "Completed" section. + +### README + +Update `README.md` when: +- A new environment variable is introduced. +- The Docker run command changes (new volume, port, or flag). +- A new top-level feature is added that HR administrators need to know about. + +Do not add implementation details to README — that belongs in code comments or AGENTS.md. + +--- + +## Constraints & Non-Goals + +- **No authentication.** This is intentional. The app runs on a trusted LAN. Do not add auth without explicit direction from the project owner. +- **No external dependencies beyond what's in `package.json`.** Avoid introducing new npm packages unless they solve a clearly scoped problem. Prefer using existing stack capabilities. +- **No client-side routing library.** Navigation between Violation Form, Dashboard, and modals is handled via `App.jsx` state (`view` prop). Do not introduce React Router unless the navigation model meaningfully grows beyond 3–4 views. +- **No test suite currently.** If adding tests, use Vitest for frontend and a lightweight assertion library for backend routes. Do not add a full testing framework without discussion. +- **SQLite only.** Do not introduce Postgres, Redis, or other datastores. The single-file DB on a Docker volume is the correct solution for this scale. diff --git a/.claude/worktrees/musing-bell/Dockerfile b/.claude/worktrees/musing-bell/Dockerfile new file mode 100644 index 0000000..5aa27ad --- /dev/null +++ b/.claude/worktrees/musing-bell/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-alpine AS builder +WORKDIR /build +COPY package.json ./ +RUN npm install +COPY client/package.json ./client/ +RUN cd client && npm install +COPY client/ ./client/ +RUN cd client && npm run build + +# ── Version metadata ────────────────────────────────────────────────────────── +# Pass these at build time: +# docker build --build-arg GIT_SHA=$(git rev-parse HEAD) \ +# --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) . +ARG GIT_SHA=dev +ARG BUILD_TIME=unknown +RUN echo "{\"sha\":\"${GIT_SHA}\",\"shortSha\":\"${GIT_SHA:0:7}\",\"buildTime\":\"${BUILD_TIME}\"}" \ + > /build/client/dist/version.json + +FROM node:20-alpine AS production +RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +ENV NODE_ENV=production +ENV PORT=3001 +ENV DB_PATH=/data/cpas.db +WORKDIR /app +COPY --from=builder /build/node_modules ./node_modules +COPY --from=builder /build/client/dist ./client/dist +COPY server.js ./ +COPY package.json ./ +COPY db/ ./db/ +COPY pdf/ ./pdf/ +COPY demo/ ./demo/ +COPY client/public/static ./client/dist/static +RUN mkdir -p /data +EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 +CMD ["node", "server.js"] diff --git a/.claude/worktrees/musing-bell/MOBILE_RESPONSIVE.md b/.claude/worktrees/musing-bell/MOBILE_RESPONSIVE.md new file mode 100644 index 0000000..91d1325 --- /dev/null +++ b/.claude/worktrees/musing-bell/MOBILE_RESPONSIVE.md @@ -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 ` + {/* 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 diff --git a/.claude/worktrees/musing-bell/README.md b/.claude/worktrees/musing-bell/README.md new file mode 100644 index 0000000..db9a258 --- /dev/null +++ b/.claude/worktrees/musing-bell/README.md @@ -0,0 +1,397 @@ +# CPAS Violation Tracker + +Single-container Dockerized web app for CPAS violation documentation and workforce standing management. +Built with **React + Vite** (frontend), **Node.js + Express** (backend), **SQLite** (database), and **Puppeteer** (PDF generation). + +> © Jason Stedwell · [git.alwisp.com/jason/cpas](https://git.alwisp.com/jason/cpas) + +--- + +## The only requirement on your machine: Docker Desktop + +Everything else — Node.js, npm, React build, Chromium for PDF — happens inside Docker. + +--- + +## Quickstart (Local) + +```bash +# 1. Build the image (installs all deps + compiles React inside Docker) +docker build -t cpas . + +# 2. Run it +docker run -d --name cpas \ + -p 3001:3001 \ + -v cpas-data:/data \ + cpas + +# 3. Open +# http://localhost:3001 +``` + +## Update After Code Changes + +```bash +docker build -t cpas . +docker stop cpas && docker rm cpas +docker run -d --name cpas -p 3001:3001 -v cpas-data:/data cpas +``` + +--- + +## Deploying on Unraid + +### Step 1 — Build and export the image on your dev machine + +```bash +docker build -t cpas:latest . +docker save cpas:latest | gzip > cpas-latest.tar.gz +``` + +### Step 2 — Load the image on Unraid + +Transfer `cpas-latest.tar.gz` to your Unraid server, then load it via the Unraid terminal: + +```bash +docker load < /path/to/cpas-latest.tar.gz +``` + +Confirm the image is present: + +```bash +docker images | grep cpas +``` + +### Step 3 — Create the appdata directory + +```bash +mkdir -p /mnt/user/appdata/cpas/db +``` + +### Step 4 — Run the container + +This is the verified working `docker run` command for Unraid (bridge networking with static IP): + +```bash +docker run \ + -d \ + --name='cpas' \ + --net='br0' \ + --ip='10.2.0.14' \ + --pids-limit 2048 \ + -e TZ="America/Chicago" \ + -e HOST_OS="Unraid" \ + -e HOST_HOSTNAME="ALPHA" \ + -e HOST_CONTAINERNAME="cpas" \ + -e 'PORT'='3001' \ + -e 'DB_PATH'='/data/cpas.db' \ + -l net.unraid.docker.managed=dockerman \ + -l net.unraid.docker.webui='http://[IP]:[PORT:3001]' \ + -v '/mnt/user/appdata/cpas/db':'/data':'rw' \ + cpas:latest +``` + +Access the app at `http://10.2.0.14:3001` (or whatever static IP you assigned). + +### Key settings explained + +| Setting | Value | Notes | +|---------|-------|-------| +| `--net` | `br0` | Unraid custom bridge network — gives the container its own LAN IP | +| `--ip` | `10.2.0.14` | Static IP on your LAN — adjust to match your subnet | +| `--pids-limit` | `2048` | Required — Puppeteer/Chromium spawns many processes for PDF generation; default Unraid limit is too low and will cause PDF failures | +| `PORT` | `3001` | Express listen port inside the container | +| `DB_PATH` | `/data/cpas.db` | SQLite database path inside the container | +| Volume | `/mnt/user/appdata/cpas/db` → `/data` | Persists the database across container restarts and rebuilds | + +### Updating on Unraid + +1. Build and export the new image on your dev machine (Step 1 above) +2. Load it on Unraid: `docker load < cpas-latest.tar.gz` +3. Stop and remove the old container: `docker stop cpas && docker rm cpas` +4. Re-run the `docker run` command from Step 4 — the volume mount preserves all data + +> **Note:** The `--pids-limit 2048` flag is critical. Without it, Chromium hits Unraid's default PID limit and PDF generation silently fails or crashes the container. + +--- + +## Stakeholder Demo + +A standalone demo page with synthetic data is available at `/demo` (e.g. `http://localhost:3001/demo`). +It is served as a static route before the SPA catch-all and requires no authentication. +Useful for showing the app to stakeholders without exposing live employee data. + +--- + +## Features + +### Company Dashboard +- Live table of all employees sorted by active CPAS points (highest risk first) +- Summary stat cards: total employees, elite standing (0 pts), with active points, at-risk count, highest active score +- **At-risk badge**: flags employees within 2 points of the next tier escalation +- Search/filter by name, department, or supervisor +- **Department filter**: pre-loaded dropdown of all departments for quick scoped views +- Click any employee name to open their full profile modal +- **📋 Audit Log** button — filterable, paginated view of all system write actions + +### Violation Form +- Select existing employee or enter new employee by name +- **Employee intelligence**: shows current CPAS standing badge and 90-day violation count before submitting +- Violation type dropdown grouped by category; shows prior 90-day counts inline +- **Recidivist auto-escalation**: if an employee has prior violations of the same type, points slider auto-sets to maximum per policy +- Repeat offense badge with prior count displayed +- Context-sensitive fields (time, minutes late, amount, location, description) shown only when relevant to violation type +- **Tier crossing warning** (TierWarning component): previews what tier the new points would push the employee into before submission +- Point slider for discretionary adjustments within the violation's min/max range +- **Employee Acknowledgment section**: optional "received by employee" name and date fields; when filled, the PDF signature block shows the recorded acknowledgment instead of a blank signature line +- One-click PDF download immediately after submission +- **Toast notifications**: success/error/warning feedback for form submissions, validation, and PDF downloads + +### Employee Profile Modal +- Full violation history with resolution status and **amendment count badge** per record +- **✎ Edit Employee** button — update name, department, supervisor, or notes inline +- **Merge Duplicate** tab — reassign all violations from a duplicate record and delete it +- **Amend** button per active violation — edit non-scoring fields (location, notes, witness, acknowledgment, etc.) with a full field-level diff history +- Negate / restore individual violations (soft delete with resolution type + notes) +- Hard delete option for data entry errors +- PDF download for any historical violation record +- **Notes & Flags** — free-text notes (e.g. "on PIP", "union member") with quick-add tag buttons; visible in the profile modal without affecting scoring +- **Point Expiration Timeline** — shows when each active violation rolls off the 90-day window, with a progress bar, days-remaining countdown, and projected tier-drop indicators +- **Toast notifications** for all actions: negate, restore, delete, amend, PDF download, employee edit + +### Audit Log +- Append-only log of every write action: employee created/edited/merged, violation logged/amended/negated/restored/deleted +- Filterable by entity type (employee / violation) and action +- Paginated with load-more; accessible from the Dashboard toolbar + +### Violation Amendment +- Edit submitted violations' non-scoring fields without delete-and-resubmit +- Point values, violation type, and incident date are immutable +- Every change is stored as a field-level diff (old → new value) with timestamp and actor + +### In-App Documentation +- **? Docs** button in the navbar opens a slide-in admin reference panel +- Covers feature map, CPAS tier system, workflow guidance, and roadmap +- No external link required; always reflects current deployed version + +### Toast Notification System +- Global toast notifications for all user actions across the application +- Four variants: success (green), error (red), warning (gold), info (blue) +- Auto-dismiss with configurable duration and visual progress bar countdown +- Slide-in animation; stacks up to 5 notifications simultaneously +- Consistent dark theme styling matching the rest of the UI + +### App Footer +- **© Jason Stedwell** copyright with auto-advancing year +- **Live dev ticker**: real-time elapsed counter since first commit (`2026-03-06`), ticking every second in `Xd HHh MMm SSs` format with a pulsing green dot +- **Gitea repo link** with icon — links directly to `git.alwisp.com/jason/cpas` + +### CPAS Tier System + +| Points | Tier | Label | +|--------|------|-------| +| 0–4 | 0–1 | Elite Standing | +| 5–9 | 1 | Realignment | +| 10–14 | 2 | Administrative Lockdown | +| 15–19 | 3 | Verification | +| 20–24 | 4 | Risk Mitigation | +| 25–29 | 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__.pdf` +- PDF captures prior active points **at the time of the incident** (snapshot stored on insert) +- **Acknowledgment rendering**: if the violation has an `acknowledged_by` value, the employee signature block on the PDF shows the recorded name and date with an "Acknowledged" badge; otherwise, blank signature lines are rendered for wet-ink signing + +--- + +## API Reference + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/health` | Health check | +| GET | `/api/employees` | List all employees (includes `notes`) | +| POST | `/api/employees` | Create or upsert employee | +| PATCH | `/api/employees/:id` | Edit name, department, supervisor, or notes | +| POST | `/api/employees/:id/merge` | Merge duplicate employee; reassigns all violations | +| GET | `/api/employees/:id/score` | Get active CPAS score for employee | +| GET | `/api/employees/:id/expiration` | Active violation roll-off timeline with days remaining | +| PATCH | `/api/employees/:id/notes` | Save employee notes only (shorthand) | +| GET | `/api/dashboard` | All employees with active points + violation counts | +| POST | `/api/violations` | Log a new violation (accepts `acknowledged_by`, `acknowledged_date`) | +| GET | `/api/violations/employee/:id` | Violation history with resolutions + amendment counts | +| PATCH | `/api/violations/:id/negated` | Negate a violation (soft delete + resolution record) | +| PATCH | `/api/violations/:id/restore` | Restore a negated violation | +| PATCH | `/api/violations/:id/amend` | Amend non-scoring fields with field-level diff logging | +| GET | `/api/violations/:id/amendments` | Get amendment history for a violation | +| DELETE | `/api/violations/:id` | Hard delete a violation | +| GET | `/api/violations/:id/pdf` | Download violation PDF | +| GET | `/api/audit` | Paginated audit log (filterable by `entity_type`, `entity_id`) | + +--- + +## Project Structure + +``` +cpas/ +├── Dockerfile # Multi-stage: builds React + runs Express w/ Chromium +├── .dockerignore +├── package.json # Backend (Express) deps +├── server.js # API + static file server +├── db/ +│ ├── schema.sql # Tables + 90-day active score view +│ └── database.js # SQLite connection (better-sqlite3) + auto-migrations +├── pdf/ +│ ├── generator.js # Puppeteer PDF generation +│ └── template.js # HTML template (loads logo from disk, ack signature rendering) +├── demo/ # Static stakeholder demo page (served at /demo) +└── client/ # React frontend (Vite) + ├── package.json + ├── vite.config.js + ├── index.html + └── src/ + ├── main.jsx + ├── App.jsx # Root app + AppFooter (copyright, dev ticker, Gitea link) + ├── data/ + │ └── violations.js # All CPAS violation definitions + groups + ├── hooks/ + │ └── useEmployeeIntelligence.js # Score + history hook + └── components/ + ├── CpasBadge.jsx # Tier badge + color logic + ├── TierWarning.jsx # Pre-submit tier crossing alert + ├── Dashboard.jsx # Company-wide leaderboard + audit log trigger + ├── ViolationForm.jsx # Violation entry form + ack signature fields + ├── EmployeeModal.jsx # Employee profile + history modal + ├── EditEmployeeModal.jsx # Employee edit + merge duplicate + ├── AmendViolationModal.jsx # Non-scoring field amendment + diff history + ├── AuditLog.jsx # Filterable audit log panel + ├── NegateModal.jsx # Negate/resolve violation dialog + ├── ViolationHistory.jsx # Violation list component + ├── ExpirationTimeline.jsx # Per-violation 90-day roll-off countdown + ├── EmployeeNotes.jsx # Inline notes editor with quick-add HR tags + ├── ToastProvider.jsx # Global toast notification system + useToast hook + └── ReadmeModal.jsx # In-app admin documentation panel +``` + +--- + +## Database Schema + +Six tables + one view: + +- **`employees`** — id, name, department, supervisor, **notes** +- **`violations`** — full incident record including `prior_active_points` snapshot at time of logging, `acknowledged_by` and `acknowledged_date` for employee acknowledgment +- **`violation_resolutions`** — resolution type, details, resolved_by (linked to violations) +- **`violation_amendments`** — field-level diff log for violation edits; one row per changed field per amendment +- **`audit_log`** — append-only record of every write action (action, entity_type, entity_id, performed_by, details, timestamp) +- **`active_cpas_scores`** (view) — sum of points for non-negated violations in rolling 90 days, grouped by employee + +--- + +## Amendable Fields + +Point values, violation type, and incident date are **immutable** after submission. The following fields can be amended: + +| Field | Notes | +|-------|-------| +| `incident_time` | Time of day the incident occurred | +| `location` | Where the incident took place | +| `details` | Narrative description | +| `submitted_by` | Supervisor who submitted | +| `witness_name` | Witness on record | +| `acknowledged_by` | Employee who acknowledged receipt | +| `acknowledged_date` | Date of employee acknowledgment | + +--- + +## Roadmap + +### ✅ Completed + +| Phase | Feature | Description | +|-------|---------|-------------| +| 1 | Container scaffold | Docker multi-stage build, Express server, SQLite schema | +| 1 | Base violation form | Employee fields, violation type, incident date, point submission | +| 2 | Employee intelligence | Live CPAS standing badge and 90-day count shown before submitting | +| 2 | Prior violation highlighting | Violation dropdown annotates types with 90-day recurrence counts | +| 2 | Recidivist auto-escalation | Points slider auto-maximizes on repeat same-type violations | +| 2 | Violation history | Per-employee history list with resolution status | +| 3 | PDF generation | Puppeteer/Chromium PDF per violation, downloadable immediately post-submit | +| 3 | Prior-points snapshot | `prior_active_points` captured at insert time for accurate historical PDFs | +| 4 | Company dashboard | Sortable employee table with live tier badges and at-risk flags | +| 4 | Stat cards | Summary counts: total, clean, active, at-risk, highest score | +| 4 | Tier crossing warning | Pre-submit alert when new points push employee to next tier | +| 4 | Employee profile modal | Full history, negate/restore, hard delete, per-record PDF download | +| 4 | Negate & restore | Soft-delete violations with resolution type + notes, fully reversible | +| 5 | Employee edit / merge | Update employee name/dept/supervisor; merge duplicate records without losing history | +| 5 | Violation amendment | Edit non-scoring fields with field-level audit trail | +| 5 | Audit log | Append-only log of all system writes; filterable panel in the dashboard | +| 6 | Employee notes / flags | Free-text notes on employee record with quick-add HR tags; does not affect scoring | +| 6 | Point expiration timeline | Per-violation roll-off countdown with tier-drop projections | +| 6 | In-app documentation | Admin usage guide and feature map accessible from the navbar | +| 7 | Acknowledgment signature field | "Received by employee" name + date on the violation form; renders on the PDF replacing blank signature lines with recorded acknowledgment | +| 7 | Toast notification system | Global success/error/warning/info notifications for all user actions; auto-dismiss with progress bar; consistent dark theme | +| 7 | Department dropdown | Pre-loaded select on the violation form replacing free-text department input; shared `DEPARTMENTS` constant | +| 8 | Stakeholder demo page | Standalone `/demo` route with synthetic data; static HTML served before SPA catch-all; useful for non-live presentations | +| 8 | App footer | Copyright (© Jason Stedwell), live dev ticker since first commit, Gitea repo icon+link | + +--- + +### 📋 Proposed + +Effort ratings: 🟢 Low · 🟡 Medium · 🔴 High + +#### Quick Wins (High value, low effort) + +| Feature | Effort | Description | +|---------|--------|-------------| +| Column sort on dashboard | 🟢 | Click `Tier`, `Active Points`, or `Department` headers to sort in-place; one `useState` + comparator, no API changes | +| Department filter on dashboard | 🟢 | Multi-select dropdown to scope the employee table by department; `DEPARTMENTS` constant already exists | +| Keyboard shortcut: New Violation | 🟢 | `N` key triggers tab switch to the violation form; ~5 lines of code | + +#### Reporting & Analytics + +| Feature | Effort | Description | +|---------|--------|-------------| +| Violation trend chart | 🟡 | Line/bar chart of violations per day/week/month, filterable by department or supervisor; useful for identifying systemic patterns | +| Department heat map | 🟡 | Grid view showing violation density and average CPAS score by department; helps supervisors identify team-level risk | +| Violation sparklines per employee | 🟡 | Tiny inline bar chart of points over the last 6 months in the employee modal | + +#### Employee Management + +| Feature | Effort | Description | +|---------|--------|-------------| +| Supervisor scoped view | 🟡 | Dashboard filtered to a supervisor's direct reports, accessible via URL param (`?supervisor=Name`); no schema changes required | +| Employee photo / avatar | 🟢 | Optional avatar upload stored alongside the employee record; shown in the profile modal and dashboard row | + +#### Violation Workflow + +| Feature | Effort | Description | +|---------|--------|-------------| +| Draft / pending violations | 🟡 | Save a violation as draft before finalizing; useful when incidents need review before being officially logged | +| Violation templates | 🟢 | Pre-fill the form with a saved violation type + common details for frequently logged incidents | + +#### Notifications & Escalation + +| Feature | Effort | Description | +|---------|--------|-------------| +| Tier escalation alerts | 🟡 | Email or in-app notification when an employee crosses into Tier 2+ so the relevant supervisor is automatically informed | +| At-risk threshold config | 🟢 | Make the "at-risk" warning threshold (currently hardcoded at 2 pts) configurable per deployment via an env var | +| version.json / build badge | 🟢 | Inject git SHA + build timestamp into a static file during `docker build`; surfaced in the footer and `/api/health` | + +#### Infrastructure & Ops + +| Feature | Effort | Description | +|---------|--------|-------------| +| Multi-user auth | 🔴 | Simple login with role-based access (admin, supervisor, read-only); currently the app runs on a trusted internal network with no auth | +| Automated DB backup | 🟡 | Cron job or Docker health hook to snapshot `/data/cpas.db` to a mounted backup volume or remote location on a schedule | +| Dark/light theme toggle | 🟡 | The UI is currently dark-only; a toggle would improve usability in bright environments | + +--- + +*Proposed features are suggestions based on common HR documentation workflows. Priority and implementation order should be driven by actual operational needs.* diff --git a/.claude/worktrees/musing-bell/README_UNRAID_INSTALL.md b/.claude/worktrees/musing-bell/README_UNRAID_INSTALL.md new file mode 100644 index 0000000..ee18340 --- /dev/null +++ b/.claude/worktrees/musing-bell/README_UNRAID_INSTALL.md @@ -0,0 +1,219 @@ +# CPAS Violation Tracker — Unraid Installation Guide + +> **Applies to:** Unraid 6.12+ | Single container | Port 3001 +> **Host requirement:** Docker Desktop only — no Node.js needed + +--- + +## Overview + +The Docker image is fully self-contained. All dependencies and the compiled +React frontend are baked in during the build. You only need Docker Desktop +on your local machine to build and export the image. + +--- + +## Part 1 — Build the Docker Image Locally + +Open a terminal in the unzipped project folder: + +```bash +docker build -t cpas-tracker . +``` + +This single command: +- Installs backend (Node/Express) dependencies +- Installs frontend (React/Vite) dependencies +- Compiles the React app +- Packages everything into one lean image + +No npm, no Node.js required on your machine beyond Docker. + +--- + +## Part 2 — Export the Image + +```bash +docker save cpas-tracker | gzip > cpas-tracker.tar.gz +``` + +--- + +## Part 3 — Transfer to Unraid + +### Option A — Windows SMB (Recommended, no terminal) +1. Open File Explorer → address bar → `\\[YOUR-UNRAID-IP]` +2. Open the **appdata** share +3. Create a folder named `cpas` +4. Drag `cpas-tracker.tar.gz` into `\\[YOUR-UNRAID-IP]\appdata\cpas\` + +### Option B — SCP (Mac/Linux) +```bash +scp cpas-tracker.tar.gz root@[YOUR-UNRAID-IP]:/mnt/user/appdata/cpas/ +``` + +--- + +## Part 4 — Prepare Unraid (Terminal — one time only) + +1. In Unraid GUI → **Tools** → **Terminal** +2. Run: + +```bash +mkdir -p /mnt/user/appdata/cpas/db +docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz +``` + +Expected output: +``` +Loaded image: cpas-tracker:latest +``` + +3. Close the terminal — no further terminal use needed for normal operation. + +--- + +## Part 5 — Add the Container in Unraid GUI + +### 5.1 Navigate to Docker tab +1. Click **Docker** in the top nav +2. Confirm Docker is **Enabled** (green toggle) +3. Scroll to bottom → click **Add Container** +4. Toggle **Advanced View ON** (top-right of the form) + +--- + +### 5.2 Basic Settings + +| Field | Value | +|---|---| +| **Name** | `cpas-tracker` | +| **Repository** | `cpas-tracker` | +| **Docker Hub URL** | *(leave blank — local image)* | +| **WebUI** | `http://[IP]:[PORT:3001]` | +| **Network Type** | `Bridge` | +| **Privileged** | `Off` | +| **Restart Policy** | `Unless Stopped` | +| **Console shell** | `bash` | + +> Setting the WebUI field enables a one-click launch icon on the Docker tab. + +--- + +### 5.3 Port Mapping + +Click **Add another Path, Port, Variable, Label or Device** + +| Setting | Value | +|---|---| +| Config Type | `Port` | +| Name | `Web UI` | +| Container Port | `3001` | +| Host Port | `3001` | +| Protocol | `TCP` | + +--- + +### 5.4 Volume Mapping (Database Persistence) + +Click **Add another Path, Port, Variable, Label or Device** + +| Setting | Value | +|---|---| +| Config Type | `Path` | +| Name | `Database` | +| Container Path | `/data` | +| Host Path | `/mnt/user/appdata/cpas/db` | +| Access Mode | `Read/Write` | + +> The SQLite database lives here and survives container restarts and image updates. + +--- + +### 5.5 Environment Variables + +Click **Add another Path, Port, Variable, Label or Device** for each: + +**Variable 1 — Port** + +| Setting | Value | +|---|---| +| Config Type | `Variable` | +| Name | `Port` | +| Key | `PORT` | +| Value | `3001` | + +**Variable 2 — Database Path** + +| Setting | Value | +|---|---| +| Config Type | `Variable` | +| Name | `Database Path` | +| Key | `DB_PATH` | +| Value | `/data/cpas.db` | + +--- + +### 5.6 Apply + +1. Click **Apply** at the bottom +2. Watch the progress log — wait for "Container started" +3. Click **Done** + +--- + +## Part 6 — Verify + +1. Docker tab → **cpas-tracker** should show a green icon +2. Click the container icon → **WebUI** + Or open: `http://[YOUR-UNRAID-IP]:3001` +3. Confirm **● API connected** appears in the header +4. Health check: `http://[YOUR-UNRAID-IP]:3001/api/health` + → `{"status":"ok","timestamp":"..."}` + +--- + +## Part 7 — Updating After Code Changes + +### Locally: +```bash +docker build -t cpas-tracker . +docker save cpas-tracker | gzip > cpas-tracker.tar.gz +``` + +### Transfer to Unraid (same as Part 3) + +### On Unraid — GUI only after first load: +1. Copy new tar.gz to Unraid (SMB drag-and-drop) +2. **Tools → Terminal** → `docker load < /mnt/user/appdata/cpas/cpas-tracker.tar.gz` +3. **Docker tab** → click `cpas-tracker` icon → **Restart** + +> Your database at `/mnt/user/appdata/cpas/db/cpas.db` is never touched during updates. + +--- + +## Troubleshooting + +| Problem | Fix | +|---|---| +| Container won't start | Docker tab → container icon → **Logs** | +| Port 3001 conflict | Change Host Port to `3002` in Edit Container | +| "API unreachable" in UI | Confirm green icon, check Logs, try Restart | +| DB permission error | Terminal: `chmod 755 /mnt/user/appdata/cpas/db` | +| Inspect DB directly | Terminal: `docker exec -it cpas-tracker sh` then `sqlite3 /data/cpas.db ".tables"` | + +--- + +## Quick Reference — Unraid Docker Tab Actions + +| Action | Steps | +|---|---| +| Open app | Container icon → WebUI | +| View logs | Container icon → Logs | +| Restart | Container icon → Restart | +| Edit settings | Container icon → Edit | +| Stop/Start | Container icon → Stop / Start | + +--- + +*CPAS Violation Tracker — Phase 1 | Message Point Media internal use* diff --git a/.claude/worktrees/musing-bell/client/index.html b/.claude/worktrees/musing-bell/client/index.html new file mode 100644 index 0000000..a72bce6 --- /dev/null +++ b/.claude/worktrees/musing-bell/client/index.html @@ -0,0 +1,17 @@ + + + + + + CPAS Violation Tracker + + + +
+ + + diff --git a/.claude/worktrees/musing-bell/client/package.json b/.claude/worktrees/musing-bell/client/package.json new file mode 100644 index 0000000..36639a5 --- /dev/null +++ b/.claude/worktrees/musing-bell/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "cpas-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.8", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.2" + } +} diff --git a/.claude/worktrees/musing-bell/client/public/static/mpm-logo.png b/.claude/worktrees/musing-bell/client/public/static/mpm-logo.png new file mode 100644 index 0000000..02e50cc Binary files /dev/null and b/.claude/worktrees/musing-bell/client/public/static/mpm-logo.png differ diff --git a/.claude/worktrees/musing-bell/client/public/version.json b/.claude/worktrees/musing-bell/client/public/version.json new file mode 100644 index 0000000..d23d125 --- /dev/null +++ b/.claude/worktrees/musing-bell/client/public/version.json @@ -0,0 +1,5 @@ +{ + "sha": "dev", + "shortSha": "dev", + "buildTime": null +} diff --git a/.claude/worktrees/musing-bell/client/src/App.jsx b/.claude/worktrees/musing-bell/client/src/App.jsx new file mode 100644 index 0000000..2b02499 --- /dev/null +++ b/.claude/worktrees/musing-bell/client/src/App.jsx @@ -0,0 +1,273 @@ +import React, { useState, useEffect } from 'react'; +import ViolationForm from './components/ViolationForm'; +import Dashboard from './components/Dashboard'; +import ReadmeModal from './components/ReadmeModal'; +import ToastProvider from './components/ToastProvider'; +import './styles/mobile.css'; + +const REPO_URL = 'https://git.alwisp.com/jason/cpas'; +const PROJECT_START = new Date('2026-03-06T11:33:32-06:00'); + +function elapsed(from) { + const totalSec = Math.floor((Date.now() - from.getTime()) / 1000); + const d = Math.floor(totalSec / 86400); + const h = Math.floor((totalSec % 86400) / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`; +} + +function DevTicker() { + const [tick, setTick] = useState(() => elapsed(PROJECT_START)); + useEffect(() => { + const id = setInterval(() => setTick(elapsed(PROJECT_START)), 1000); + return () => clearInterval(id); + }, []); + return ( + + + {tick} + + ); +} + +function GiteaIcon() { + return ( + + + + ); +} + +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 ( + <> + +
+ © {year} Jason Stedwell + · + + · + + cpas + + {sha && sha !== 'dev' && ( + <> + · + + {sha} + + + )} +
+ + ); +} + +const tabs = [ + { id: 'dashboard', label: '📊 Dashboard' }, + { id: 'violation', label: '+ New Violation' }, +]; + +// Responsive utility hook +function useMediaQuery(query) { + const [matches, setMatches] = useState(false); + useEffect(() => { + const media = window.matchMedia(query); + if (media.matches !== matches) setMatches(media.matches); + const listener = () => setMatches(media.matches); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, [matches, query]); + return matches; +} + +const s = { + app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' }, + nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, + logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' }, + logoImg: { height: '28px', marginRight: '10px' }, + logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' }, + tab: (active) => ({ + padding: '18px 22px', + color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)', + borderBottom: active ? '3px solid #d4af37' : '3px solid transparent', + cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px', + background: 'none', border: 'none', + }), + docsBtn: { + marginLeft: 'auto', + background: 'none', + border: '1px solid #2a2b3a', + color: '#9ca0b8', + borderRadius: '6px', + padding: '6px 14px', + fontSize: '12px', + cursor: 'pointer', + fontWeight: 600, + letterSpacing: '0.3px', + display: 'flex', + alignItems: 'center', + gap: '6px', + }, + main: { flex: 1 }, + card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' }, +}; + +// Mobile-responsive style overrides +const mobileStyles = ` + @media (max-width: 768px) { + .app-nav { + padding: 0 16px !important; + flex-wrap: wrap; + justify-content: center; + } + .logo-wrap { + margin-right: 0 !important; + padding: 12px 0 !important; + width: 100%; + justify-content: center; + border-bottom: 1px solid #1a1b22; + } + .nav-tabs { + display: flex; + width: 100%; + justify-content: space-around; + } + .nav-tab { + flex: 1; + text-align: center; + padding: 14px 8px !important; + font-size: 13px !important; + } + .docs-btn { + position: absolute; + top: 16px; + right: 16px; + padding: 4px 10px !important; + font-size: 11px !important; + } + .docs-btn span:first-child { + display: none; + } + .main-card { + margin: 12px !important; + border-radius: 8px !important; + } + } + + @media (max-width: 480px) { + .logo-text { + font-size: 16px !important; + } + .logo-img { + height: 24px !important; + } + } +`; + +const sf = { + footer: { + borderTop: '1px solid #1a1b22', + padding: '12px 40px', + display: 'flex', + alignItems: 'center', + gap: '12px', + fontSize: '11px', + color: 'rgba(248,249,250,0.35)', + background: '#000', + flexShrink: 0, + }, + copy: { color: 'rgba(248,249,250,0.35)' }, + sep: { color: 'rgba(248,249,250,0.15)' }, + link: { + color: 'rgba(248,249,250,0.35)', + textDecoration: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + transition: 'color 0.15s', + }, +}; + +export default function App() { + const [tab, setTab] = useState('dashboard'); + const [showReadme, setShowReadme] = useState(false); + const [version, setVersion] = useState(null); + const isMobile = useMediaQuery('(max-width: 768px)'); + + useEffect(() => { + fetch('/version.json') + .then(r => r.ok ? r.json() : null) + .then(v => { if (v) setVersion(v); }) + .catch(() => {}); + }, []); + + return ( + + +
+ + +
+
+ {tab === 'dashboard' ? : } +
+
+ + + + {showReadme && setShowReadme(false)} />} +
+
+ ); +} diff --git a/.claude/worktrees/musing-bell/client/src/components/AmendViolationModal.jsx b/.claude/worktrees/musing-bell/client/src/components/AmendViolationModal.jsx new file mode 100644 index 0000000..c9dbd91 --- /dev/null +++ b/.claude/worktrees/musing-bell/client/src/components/AmendViolationModal.jsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Amend Violation
+
+ CPAS-{String(violation.id).padStart(5, '0')} · {violation.violation_name} · {violation.incident_date} +
+
+ +
+ +
+
+ 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. +
+ + {error &&
{error}
} + + {Object.entries(FIELD_LABELS).map(([field, label]) => ( +
+
{label}
+ {field === 'details' ? ( +