feat: custom violation types — persist, manage, and use in violation form

Adds a full CRUD system for user-defined violation types stored in a new
violation_types table. Custom types appear in the violation dropdown alongside
hardcoded types, grouped by category, with ✦ marker and a green Custom badge.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-18 16:23:21 -05:00
parent 563c5043c6
commit 95d56b5018
4 changed files with 527 additions and 5 deletions
+124 -5
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
import { violationData, violationGroups } from '../data/violations';
import useEmployeeIntelligence from '../hooks/useEmployeeIntelligence';
import CpasBadge from './CpasBadge';
import TierWarning from './TierWarning';
import ViolationHistory from './ViolationHistory';
import ViolationTypeModal from './ViolationTypeModal';
import { useToast } from './ToastProvider';
import { DEPARTMENTS } from '../data/departments';
@@ -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' | <editing object>
const toast = useToast();
const intel = useEmployeeIntelligence(form.employeeId || null);
useEffect(() => {
axios.get('/api/employees').then(r => setEmployees(r.data)).catch(() => {});
fetchCustomTypes();
}, []);
const fetchCustomTypes = () => {
axios.get('/api/violation-types').then(r => setCustomTypes(r.data)).catch(() => {});
};
// Build a map of custom types keyed by type_key for fast lookup
const customTypeMap = useMemo(() =>
Object.fromEntries(customTypes.map(t => [t.type_key, t])),
[customTypes]
);
// Merge hardcoded and custom violation groups for the dropdown
const mergedGroups = useMemo(() => {
const groups = {};
// Start with all hardcoded groups
Object.entries(violationGroups).forEach(([cat, items]) => {
groups[cat] = [...items];
});
// Add custom types into their respective category, or create new group
customTypes.forEach(t => {
const item = {
key: t.type_key,
name: t.name,
category: t.category,
minPoints: t.min_points,
maxPoints: t.max_points,
chapter: t.chapter || '',
description: t.description || '',
fields: t.fields,
isCustom: true,
customId: t.id,
};
if (!groups[t.category]) groups[t.category] = [];
groups[t.category].push(item);
});
return groups;
}, [customTypes]);
// Resolve a violation definition from either the hardcoded registry or custom types
const resolveViolation = key => {
if (violationData[key]) return violationData[key];
const ct = customTypeMap[key];
if (ct) return {
name: ct.name,
category: ct.category,
chapter: ct.chapter || '',
description: ct.description || '',
minPoints: ct.min_points,
maxPoints: ct.max_points,
fields: ct.fields,
isCustom: true,
customId: ct.id,
};
return null;
};
useEffect(() => {
if (!violation || !form.violationType) return;
const allTime = intel.countsAllTime[form.violationType];
@@ -72,7 +131,7 @@ export default function ViolationForm() {
const handleViolationChange = e => {
const key = e.target.value;
const v = violationData[key] || null;
const v = resolveViolation(key);
setViolation(v);
setForm(prev => ({ ...prev, violationType: key, points: v ? v.minPoints : 1 }));
};
@@ -196,16 +255,37 @@ export default function ViolationForm() {
<div style={s.grid}>
<div style={{ ...s.item, ...s.fullCol }}>
<label style={s.label}>Violation Type:</label>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
<label style={{ ...s.label, marginBottom: 0 }}>Violation Type:</label>
<div style={{ display: 'flex', gap: '6px' }}>
{violation?.isCustom && (
<button
type="button"
onClick={() => setTypeModal(customTypeMap[form.violationType])}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #4caf50', background: '#1a2e1a', color: '#4caf50', cursor: 'pointer', fontWeight: 600 }}
>
Edit Type
</button>
)}
<button
type="button"
onClick={() => setTypeModal('create')}
style={{ fontSize: '11px', padding: '3px 10px', borderRadius: '4px', border: '1px solid #d4af37', background: '#181200', color: '#ffd666', cursor: 'pointer', fontWeight: 600 }}
title="Add a new custom violation type"
>
+ Add Type
</button>
</div>
</div>
<select style={s.input} value={form.violationType} onChange={handleViolationChange} required>
<option value="">-- Select Violation Type --</option>
{Object.entries(violationGroups).map(([group, items]) => (
{Object.entries(mergedGroups).map(([group, items]) => (
<optgroup key={group} label={group}>
{items.map(v => {
const prior = priorCount90(v.key);
return (
<option key={v.key} value={v.key}>
{v.name}{prior > 0 ? `${prior}x in 90 days` : ''}
{v.name}{v.isCustom ? ' ✦' : ''}{prior > 0 ? `${prior}x in 90 days` : ''}
</option>
);
})}
@@ -216,6 +296,11 @@ export default function ViolationForm() {
{violation && (
<div style={s.contextBox}>
<strong>{violation.name}</strong>
{violation.isCustom && (
<span style={{ display: 'inline-block', marginLeft: '8px', padding: '1px 7px', borderRadius: '10px', fontSize: '10px', fontWeight: 700, background: '#1a2e1a', color: '#4caf50', border: '1px solid #4caf50' }}>
Custom
</span>
)}
{isRepeat(form.violationType) && form.employeeId && (
<span style={s.repeatBadge}>
Repeat {intel.countsAllTime[form.violationType]?.count}x prior
@@ -348,6 +433,40 @@ export default function ViolationForm() {
</div>
)}
{typeModal && (
<ViolationTypeModal
editing={typeModal === 'create' ? null : typeModal}
onClose={() => setTypeModal(null)}
onSaved={saved => {
fetchCustomTypes();
setTypeModal(null);
// Auto-select the newly created type; do nothing on delete (saved === null)
if (saved) {
const v = {
name: saved.name,
category: saved.category,
chapter: saved.chapter || '',
description: saved.description || '',
minPoints: saved.min_points,
maxPoints: saved.max_points,
fields: saved.fields,
isCustom: true,
customId: saved.id,
};
setViolation(v);
setForm(prev => ({ ...prev, violationType: saved.type_key, points: saved.min_points }));
} else {
// Type was deleted — clear selection if it was the active type
setForm(prev => {
const stillExists = violationData[prev.violationType] || false;
return stillExists ? prev : { ...prev, violationType: '', points: 1 };
});
setViolation(null);
}
}}
/>
)}
</div>
);
}