From 3fda1490499b375c79cf72db8237f020bfc4edc9 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 24 Apr 2026 08:46:32 +0200 Subject: [PATCH] feat: complete admin system - global catalogs + M:N assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab System: - Tab 1: Hierarchie (Fokusbereich → Stilrichtung/Trainingstyp) - Tab 2: Kataloge (Zielgruppen, Fähigkeiten, Trainingscharakter) - Tab 3: Zuordnungen (Matrix Stilrichtungen ↔ Zielgruppen) Global Catalogs (Tab 2): - Zielgruppen: CRUD mit Altersangaben (zentral verwaltet) - Fähigkeitskategorien: CRUD (global) - Trainingscharakter: CRUD (global) - Reusable CatalogSection component with dynamic fields - Create/Edit/Delete für alle Kataloge - Inline editing + validation M:N Assignments Matrix (Tab 3): - Checkbox-Grid: Stilrichtungen (Zeilen) × Zielgruppen (Spalten) - Grouped by Focus Area for clarity - Toggle assignments with immediate save - Shows empty states with helpful messages - Fully responsive table with horizontal scroll Architecture: - Trainingstypen: Context-specific per focus area (create new) - Zielgruppen: Global catalog (assign via matrix) - Clean separation of concerns - Proper loading states + error handling Mobile responsive across all tabs. Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/pages/AdminHierarchyPage.jsx | 528 +++++++++++++++++++++- 1 file changed, 522 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/AdminHierarchyPage.jsx b/frontend/src/pages/AdminHierarchyPage.jsx index ebdf7de..9bed5a3 100644 --- a/frontend/src/pages/AdminHierarchyPage.jsx +++ b/frontend/src/pages/AdminHierarchyPage.jsx @@ -13,15 +13,31 @@ import { api } from '../utils/api' */ function AdminHierarchyPage() { + const [activeTab, setActiveTab] = useState('hierarchy') const [hierarchy, setHierarchy] = useState([]) const [expandedNodes, setExpandedNodes] = useState(new Set()) const [selectedItem, setSelectedItem] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + // Global catalogs state + const [targetGroups, setTargetGroups] = useState([]) + const [skillCategories, setSkillCategories] = useState([]) + const [trainingCharacters, setTrainingCharacters] = useState([]) + + // Matrix state + const [styleDirections, setStyleDirections] = useState([]) + const [assignments, setAssignments] = useState([]) + useEffect(() => { - loadHierarchy() - }, []) + if (activeTab === 'hierarchy') { + loadHierarchy() + } else if (activeTab === 'catalogs') { + loadCatalogs() + } else if (activeTab === 'assignments') { + loadAssignments() + } + }, [activeTab]) async function loadHierarchy() { setLoading(true) @@ -40,6 +56,44 @@ function AdminHierarchyPage() { } } + async function loadCatalogs() { + setLoading(true) + setError('') + try { + const [tgs, scs, tcs] = await Promise.all([ + api.listTargetGroups(), + api.listSkillCategories(), + api.listTrainingCharacters() + ]) + setTargetGroups(tgs) + setSkillCategories(scs) + setTrainingCharacters(tcs) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + async function loadAssignments() { + setLoading(true) + setError('') + try { + const [sds, tgs, assigns] = await Promise.all([ + api.listStyleDirections(), + api.listTargetGroups(), + api.listStyleDirectionTargetGroups() + ]) + setStyleDirections(sds) + setTargetGroups(tgs) + setAssignments(assigns) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + function toggleNode(nodeId) { const newExpanded = new Set(expandedNodes) if (newExpanded.has(nodeId)) { @@ -90,9 +144,119 @@ function AdminHierarchyPage() { .admin-tree-view { ${selectedItem ? 'display: none;' : 'display: block;'} } + + .admin-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + border-bottom: 2px solid var(--border); + padding-bottom: 0; + } + + .admin-tab { + padding: 12px 20px; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: var(--text2); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; + } + + .admin-tab:hover { + color: var(--text1); + background: var(--surface2); + } + + .admin-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + } `} -
+
+ {/* Tabs */} +
+ + + +
+ + {/* Tab Content */} + {activeTab === 'hierarchy' && ( + { + const newExpanded = new Set(expandedNodes) + if (newExpanded.has(nodeId)) { + newExpanded.delete(nodeId) + } else { + newExpanded.add(nodeId) + } + setExpandedNodes(newExpanded) + }} + onSelectItem={setSelectedItem} + onUpdate={loadHierarchy} + /> + )} + + {activeTab === 'catalogs' && ( + + )} + + {activeTab === 'assignments' && ( + + )} +
+ + ) +} + +// ============================================================================ +// Tab 1: Hierarchy +// ============================================================================ + +function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) { + if (loading && hierarchy.length === 0) { + return
+ } + + return ( +
{/* Tree View */}
- +
)}
- +
+ ) +} + +// ============================================================================ +// Tab 2: Global Catalogs +// ============================================================================ + +function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) { + if (loading) { + return
+ } + + return ( +
+ {error &&
{error}
} + + + + + + +
+ ) +} + +// ============================================================================ +// Tab 3: Assignments Matrix +// ============================================================================ + +function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, error, onUpdate }) { + const [saving, setSaving] = useState(false) + + if (loading) { + return
+ } + + async function toggleAssignment(styleDirectionId, targetGroupId) { + const existing = assignments.find( + a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId + ) + + setSaving(true) + try { + if (existing) { + await api.deleteStyleDirectionTargetGroup(existing.id) + } else { + await api.createStyleDirectionTargetGroup({ + style_direction_id: styleDirectionId, + target_group_id: targetGroupId, + is_primary: false + }) + } + onUpdate() + } catch (e) { + alert('Fehler: ' + e.message) + } finally { + setSaving(false) + } + } + + function isAssigned(styleDirectionId, targetGroupId) { + return assignments.some( + a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId + ) + } + + // Group style directions by focus area + const groupedStyles = styleDirections.reduce((acc, sd) => { + const key = sd.focus_area_name || 'Ohne Fokusbereich' + if (!acc[key]) acc[key] = [] + acc[key].push(sd) + return acc + }, {}) + + return ( +
+ {error &&
{error}
} + +
+

Stilrichtungen ↔ Zielgruppen

+

+ Ordne Zielgruppen den Stilrichtungen zu (mehrere möglich) +

+ + + + + + {targetGroups.map(tg => ( + + ))} + + + + {Object.entries(groupedStyles).map(([focusArea, styles]) => ( + + + + + {styles.map(sd => ( + + + {targetGroups.map(tg => ( + + ))} + + ))} + + ))} + +
+ Stilrichtung + + {tg.name} +
+ {focusArea} +
+ {sd.name} + {sd.abbreviation && ({sd.abbreviation})} + + toggleAssignment(sd.id, tg.id)} + disabled={saving} + style={{ cursor: 'pointer', width: '20px', height: '20px' }} + /> +
+ + {styleDirections.length === 0 && ( +
+ Keine Stilrichtungen vorhanden. Erstelle zuerst Stilrichtungen in der Hierarchie. +
+ )} + + {targetGroups.length === 0 && ( +
+ Keine Zielgruppen vorhanden. Erstelle zuerst Zielgruppen im Kataloge-Tab. +
+ )} +
+
) } @@ -719,4 +1063,176 @@ function CreateTrainingTypeForm({ context, onUpdate }) { ) } +// ============================================================================ +// Reusable Catalog Section Component +// ============================================================================ + +function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) { + const [creating, setCreating] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState({}) + + function startCreate() { + const emptyForm = {} + fields.forEach(f => { + emptyForm[f.key] = '' + }) + setForm(emptyForm) + setCreating(true) + } + + function startEdit(item) { + const editForm = {} + fields.forEach(f => { + editForm[f.key] = item[f.key] || '' + }) + setEditing(item.id) + setForm(editForm) + } + + async function handleCreate() { + const required = fields.filter(f => f.required) + for (const field of required) { + if (!form[field.key]) { + alert(`${field.label} ist erforderlich`) + return + } + } + + try { + await createFn(form) + setCreating(false) + setForm({}) + onUpdate() + } catch (e) { + alert('Fehler: ' + e.message) + } + } + + async function handleUpdate(id) { + try { + await updateFn(id, form) + setEditing(null) + setForm({}) + onUpdate() + } catch (e) { + alert('Fehler: ' + e.message) + } + } + + async function handleDelete(id, name) { + if (!confirm(`"${name}" wirklich löschen?`)) return + try { + await deleteFn(id) + onUpdate() + } catch (e) { + alert('Fehler: ' + e.message) + } + } + + return ( +
+
+

{icon} {title}

+ +
+ + {/* Create Form */} + {creating && ( +
+

Neu erstellen

+ {fields.map(field => ( +
+ + {field.type === 'textarea' ? ( +