From 7314ae1436b907b66ac5c665ae1790160a71053c Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 24 Apr 2026 07:51:40 +0200 Subject: [PATCH] feat: Hierarchical Admin UI - Tree View for Catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New endpoint: GET /api/admin/hierarchy - Returns hierarchical structure: Focus Areas with nested Style Directions + Training Types - Style Directions include target_groups assignments (M:N) - JSON aggregation for efficient data loading Frontend: - New page: AdminHierarchyPage.jsx with Tree + Detail Panel layout - Tree View: Expandable/collapsible nodes (Focus Areas → Stilrichtungen/Trainingstypen) - Detail Panel: Shows selected item details, inline editing - Visual hierarchy: Icons, indentation, color coding - Responsive layout: Fixed 400px tree, fluid detail panel Routes: - /admin now redirects to /admin/hierarchy (new default) - /admin/hierarchy: Tree-based catalog management - /admin/catalogs: Legacy flat UI (still available) UX Improvements: - Visual hierarchy instead of flat tabs - M:N relationships visible (target groups per style) - Better navigation: Click to select, expand/collapse sections - Cleaner layout: Two-column design Co-Authored-By: Claude Sonnet 4.5 --- backend/routers/catalogs.py | 107 ++++++ frontend/src/App.jsx | 11 +- frontend/src/pages/AdminHierarchyPage.jsx | 375 ++++++++++++++++++++++ frontend/src/utils/api.js | 5 + 4 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/AdminHierarchyPage.jsx diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index dce1be5..521ded2 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -1298,3 +1298,110 @@ def delete_trainer_context(context_id: int, session=Depends(require_auth)): conn.commit() return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# HIERARCHICAL CATALOG VIEW (Admin UI Tree) +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/admin/hierarchy") +def get_admin_hierarchy(session=Depends(require_auth)): + """ + Get complete hierarchical catalog structure for admin tree view. + + Returns: + [ + { + "id": 1, + "name": "Karate", + "icon": "🥋", + "description": "...", + "style_directions": [ + { + "id": 10, + "name": "Shotokan", + "abbreviation": "SKA", + "description": "...", + "target_groups": [ + {"id": 100, "name": "Kinder", "is_primary": true} + ] + } + ], + "training_types": [ + { + "id": 20, + "name": "Breitensport", + "abbreviation": "BS", + "description": "..." + } + ] + } + ] + """ + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen die Hierarchie abrufen") + + with get_db() as conn: + cur = get_cursor(conn) + + # 1. Get all focus areas + cur.execute(""" + SELECT id, name, icon, description, sort_order, status + FROM focus_areas + WHERE status = 'active' + ORDER BY sort_order, name + """) + focus_areas = [r2d(r) for r in cur.fetchall()] + + # 2. Get all style directions with their target group assignments + cur.execute(""" + SELECT + sd.id, + sd.name, + sd.abbreviation, + sd.description, + sd.focus_area_id, + sd.sort_order, + json_agg( + json_build_object( + 'id', tg.id, + 'name', tg.name, + 'is_primary', sdtg.is_primary + ) ORDER BY tg.name + ) FILTER (WHERE tg.id IS NOT NULL) as target_groups + FROM style_directions sd + LEFT JOIN style_direction_target_groups sdtg ON sd.id = sdtg.style_direction_id + LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id + WHERE sd.status = 'active' + GROUP BY sd.id, sd.name, sd.abbreviation, sd.description, sd.focus_area_id, sd.sort_order + ORDER BY sd.sort_order, sd.name + """) + style_directions = [r2d(r) for r in cur.fetchall()] + + # 3. Get all training types + cur.execute(""" + SELECT id, name, abbreviation, description, focus_area_id, sort_order + FROM training_types + WHERE status = 'active' + ORDER BY sort_order, name + """) + training_types = [r2d(r) for r in cur.fetchall()] + + # 4. Build hierarchy + for fa in focus_areas: + fa_id = fa['id'] + + # Attach style directions + fa['style_directions'] = [ + sd for sd in style_directions + if sd['focus_area_id'] == fa_id + ] + + # Attach training types + fa['training_types'] = [ + tt for tt in training_types + if tt['focus_area_id'] == fa_id + ] + + return focus_areas diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a6753d5..a7e3996 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import ClubsPage from './pages/ClubsPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' +import AdminHierarchyPage from './pages/AdminHierarchyPage' import TrainerContextsPage from './pages/TrainerContextsPage' import './app.css' @@ -180,7 +181,15 @@ function AppRoutes() { /> } + element={} + /> + + + + } /> { + loadHierarchy() + }, []) + + async function loadHierarchy() { + setLoading(true) + setError('') + try { + const data = await api.getAdminHierarchy() + setHierarchy(data) + // Auto-expand first focus area + if (data.length > 0) { + setExpandedNodes(new Set([`fa-${data[0].id}`])) + } + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + function toggleNode(nodeId) { + const newExpanded = new Set(expandedNodes) + if (newExpanded.has(nodeId)) { + newExpanded.delete(nodeId) + } else { + newExpanded.add(nodeId) + } + setExpandedNodes(newExpanded) + } + + function selectItem(item, type) { + setSelectedItem({ ...item, _type: type }) + } + + if (loading && hierarchy.length === 0) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Left: Tree View */} +
+

Katalog-Hierarchie

+ {error &&
{error}
} + + {hierarchy.map(fa => ( + + ))} +
+ + {/* Right: Detail Panel */} +
+ {selectedItem ? ( + + ) : ( +
+

← Wähle ein Element aus dem Baum

+
+ )} +
+
+ ) +} + +// ============================================================================ +// Tree Node Components +// ============================================================================ + +function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, selectedType }) { + const nodeId = `fa-${focusArea.id}` + const isExpanded = expanded.has(nodeId) + const isSelected = selectedType === 'focus_area' && selectedId === focusArea.id + + return ( +
+ {/* Focus Area Header */} +
onSelect(focusArea, 'focus_area')} + style={{ + display: 'flex', + alignItems: 'center', + padding: '8px 12px', + borderRadius: '8px', + cursor: 'pointer', + background: isSelected ? 'var(--accent)' : 'transparent', + color: isSelected ? 'white' : 'var(--text1)', + fontWeight: 600 + }} + > + { e.stopPropagation(); onToggle(nodeId) }} + style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }} + > + {isExpanded ? '▼' : '▶'} + + {focusArea.icon} + {focusArea.name} +
+ + {/* Children: Style Directions + Training Types */} + {isExpanded && ( +
+ {/* Style Directions Section */} + {focusArea.style_directions && focusArea.style_directions.length > 0 && ( +
+
+ Stilrichtungen +
+ {focusArea.style_directions.map(sd => ( + + ))} +
+ )} + + {/* Training Types Section */} + {focusArea.training_types && focusArea.training_types.length > 0 && ( +
+
+ Trainingstypen +
+ {focusArea.training_types.map(tt => ( + + ))} +
+ )} +
+ )} +
+ ) +} + +function StyleDirectionNode({ styleDirection, onSelect, isSelected }) { + return ( +
onSelect(styleDirection, 'style_direction')} + style={{ + padding: '6px 12px', + marginBottom: '4px', + borderRadius: '6px', + cursor: 'pointer', + background: isSelected ? 'var(--accent)' : 'var(--surface2)', + color: isSelected ? 'white' : 'var(--text1)', + fontSize: '14px' + }} + > + {styleDirection.name} + {styleDirection.abbreviation && ( + + ({styleDirection.abbreviation}) + + )} + {styleDirection.target_groups && styleDirection.target_groups.length > 0 && ( +
+ Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')} +
+ )} +
+ ) +} + +function TrainingTypeNode({ trainingType, onSelect, isSelected }) { + return ( +
onSelect(trainingType, 'training_type')} + style={{ + padding: '6px 12px', + marginBottom: '4px', + borderRadius: '6px', + cursor: 'pointer', + background: isSelected ? 'var(--accent)' : 'var(--surface2)', + color: isSelected ? 'white' : 'var(--text1)', + fontSize: '14px' + }} + > + {trainingType.name} + {trainingType.abbreviation && ( + + ({trainingType.abbreviation}) + + )} +
+ ) +} + +// ============================================================================ +// Detail Panel (Edit Forms) +// ============================================================================ + +function DetailPanel({ item, onUpdate }) { + if (!item) return null + + const type = item._type + + if (type === 'focus_area') { + return + } else if (type === 'style_direction') { + return + } else if (type === 'training_type') { + return + } + + return null +} + +function FocusAreaDetail({ focusArea, onUpdate }) { + const [editing, setEditing] = useState(false) + const [form, setForm] = useState({ + name: focusArea.name, + icon: focusArea.icon, + description: focusArea.description || '' + }) + + async function handleSave() { + try { + await api.updateFocusArea(focusArea.id, form) + setEditing(false) + onUpdate() + } catch (e) { + alert('Fehler: ' + e.message) + } + } + + if (!editing) { + return ( +
+

{focusArea.icon} {focusArea.name}

+ {focusArea.description &&

{focusArea.description}

} + +
+ ) + } + + return ( +
+

Fokusbereich bearbeiten

+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + setForm({ ...form, icon: e.target.value })} + /> +
+
+ +