From 95d56b5018c6552f861deb3006f493428fafde48 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 18 Mar 2026 16:23:21 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20custom=20violation=20types=20=E2=80=94?= =?UTF-8?q?=20persist,=20manage,=20and=20use=20in=20violation=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full CRUD system for user-defined violation types stored in a new violation_types table. Custom types appear in the violation dropdown alongside hardcoded types, grouped by category, with ✦ marker and a green Custom badge. - db/database.js: auto-migration adds violation_types table on startup - server.js: GET/POST/PUT/DELETE /api/violation-types; type_key auto-generated as custom_; DELETE blocked if any violations reference the type - ViolationTypeModal.jsx: create/edit modal with name, category (datalist autocomplete from existing categories), handbook chapter reference, description/reference text, fixed vs sliding point toggle, context field checkboxes; delete with usage guard - ViolationForm.jsx: fetches custom types on mount; merges into dropdown via mergedGroups memo; resolveViolation() checks hardcoded then custom; '+ Add Type' button above dropdown; 'Edit Type' button appears when a custom type is selected; newly created type auto-selects; all audit calls flow through existing audit() helper Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/ViolationForm.jsx | 129 +++++++- client/src/components/ViolationTypeModal.jsx | 292 +++++++++++++++++++ db/database.js | 17 ++ server.js | 94 ++++++ 4 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 client/src/components/ViolationTypeModal.jsx diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx index 2ca944b..b97bc47 100755 --- a/client/src/components/ViolationForm.jsx +++ b/client/src/components/ViolationForm.jsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import axios from 'axios'; import { violationData, violationGroups } from '../data/violations'; import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence'; import CpasBadge from './CpasBadge'; import TierWarning from './TierWarning'; import ViolationHistory from './ViolationHistory'; +import ViolationTypeModal from './ViolationTypeModal'; import { useToast } from './ToastProvider'; import { DEPARTMENTS } from '../data/departments'; @@ -46,14 +47,72 @@ export default function ViolationForm() { const [status, setStatus] = useState(null); const [lastViolId, setLastViolId] = useState(null); const [pdfLoading, setPdfLoading] = useState(false); + const [customTypes, setCustomTypes] = useState([]); + const [typeModal, setTypeModal] = useState(null); // null | 'create' | const toast = useToast(); const intel = useEmployeeIntelligence(form.employeeId || null); useEffect(() => { axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {}); + fetchCustomTypes(); }, []); + const fetchCustomTypes = () => { + axios.get('/api/violation-types').then(r => setCustomTypes(r.data)).catch(() => {}); + }; + + // Build a map of custom types keyed by type_key for fast lookup + const customTypeMap = useMemo(() => + Object.fromEntries(customTypes.map(t => [t.type_key, t])), + [customTypes] + ); + + // Merge hardcoded and custom violation groups for the dropdown + const mergedGroups = useMemo(() => { + const groups = {}; + // Start with all hardcoded groups + Object.entries(violationGroups).forEach(([cat, items]) => { + groups[cat] = [...items]; + }); + // Add custom types into their respective category, or create new group + customTypes.forEach(t => { + const item = { + key: t.type_key, + name: t.name, + category: t.category, + minPoints: t.min_points, + maxPoints: t.max_points, + chapter: t.chapter || '', + description: t.description || '', + fields: t.fields, + isCustom: true, + customId: t.id, + }; + if (!groups[t.category]) groups[t.category] = []; + groups[t.category].push(item); + }); + return groups; + }, [customTypes]); + + // Resolve a violation definition from either the hardcoded registry or custom types + const resolveViolation = key => { + if (violationData[key]) return violationData[key]; + const ct = customTypeMap[key]; + if (ct) return { + name: ct.name, + category: ct.category, + chapter: ct.chapter || '', + description: ct.description || '', + minPoints: ct.min_points, + maxPoints: ct.max_points, + fields: ct.fields, + isCustom: true, + customId: ct.id, + }; + return null; + }; + useEffect(() => { if (!violation || !form.violationType) return; const allTime = intel.countsAllTime[form.violationType]; @@ -72,7 +131,7 @@ export default function ViolationForm() { const handleViolationChange = e => { const key = e.target.value; - const v = violationData[key] || null; + const v = resolveViolation(key); setViolation(v); setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 })); }; @@ -196,16 +255,37 @@ export default function ViolationForm() {
- +
+ +
+ {violation?.isCustom && ( + + )} + +
+
set('name', e.target.value)} + placeholder="e.g. Unauthorized System Access" + /> +
+ +
+ + set('category', e.target.value)} + placeholder="Select existing or type new category" + /> + + {KNOWN_CATEGORIES.map(c => +
Choose an existing category or type a new one to create a new group in the dropdown.
+
+ +
+ + set('chapter', e.target.value)} + placeholder="e.g. Chapter 4, Section 6" + /> +
+ +
+ +