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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user