feat: Hierarchical Admin UI - Tree View for Catalogs
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 <noreply@anthropic.com>
This commit is contained in:
parent
0be9fd840c
commit
7314ae1436
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<Navigate to="/admin/catalogs" replace />}
|
||||
element={<Navigate to="/admin/hierarchy" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/hierarchy"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminHierarchyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/catalogs"
|
||||
|
|
|
|||
375
frontend/src/pages/AdminHierarchyPage.jsx
Normal file
375
frontend/src/pages/AdminHierarchyPage.jsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* AdminHierarchyPage - Hierarchische Katalog-Verwaltung
|
||||
*
|
||||
* Layout:
|
||||
* ┌─ Tree (links) ─────────┬─ Detail Panel (rechts) ─┐
|
||||
* │ ▼ Fokusbereich │ │
|
||||
* │ ├─ Stilrichtungen │ [Edit-Form für │
|
||||
* │ └─ Trainingstypen │ ausgewähltes Element] │
|
||||
* └────────────────────────┴──────────────────────────┘
|
||||
*/
|
||||
|
||||
function AdminHierarchyPage() {
|
||||
const [hierarchy, setHierarchy] = useState([])
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set())
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '400px 1fr',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
height: 'calc(100vh - 100px)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Left: Tree View */}
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
background: 'var(--surface)'
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0 }}>Katalog-Hierarchie</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
|
||||
|
||||
{hierarchy.map(fa => (
|
||||
<FocusAreaNode
|
||||
key={fa.id}
|
||||
focusArea={fa}
|
||||
expanded={expandedNodes}
|
||||
onToggle={toggleNode}
|
||||
onSelect={selectItem}
|
||||
selectedId={selectedItem?.id}
|
||||
selectedType={selectedItem?._type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Detail Panel */}
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'var(--surface)'
|
||||
}}>
|
||||
{selectedItem ? (
|
||||
<DetailPanel item={selectedItem} onUpdate={loadHierarchy} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text2)', paddingTop: '60px' }}>
|
||||
<p>← Wähle ein Element aus dem Baum</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{/* Focus Area Header */}
|
||||
<div
|
||||
onClick={() => 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
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(nodeId) }}
|
||||
style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
|
||||
<span>{focusArea.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Children: Style Directions + Training Types */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
|
||||
{/* Style Directions Section */}
|
||||
{focusArea.style_directions && focusArea.style_directions.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase' }}>
|
||||
Stilrichtungen
|
||||
</div>
|
||||
{focusArea.style_directions.map(sd => (
|
||||
<StyleDirectionNode
|
||||
key={sd.id}
|
||||
styleDirection={sd}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Types Section */}
|
||||
{focusArea.training_types && focusArea.training_types.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase' }}>
|
||||
Trainingstypen
|
||||
</div>
|
||||
{focusArea.training_types.map(tt => (
|
||||
<TrainingTypeNode
|
||||
key={tt.id}
|
||||
trainingType={tt}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => 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 && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({styleDirection.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
|
||||
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => 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 && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({trainingType.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Detail Panel (Edit Forms)
|
||||
// ============================================================================
|
||||
|
||||
function DetailPanel({ item, onUpdate }) {
|
||||
if (!item) return null
|
||||
|
||||
const type = item._type
|
||||
|
||||
if (type === 'focus_area') {
|
||||
return <FocusAreaDetail focusArea={item} onUpdate={onUpdate} />
|
||||
} else if (type === 'style_direction') {
|
||||
return <StyleDirectionDetail styleDirection={item} onUpdate={onUpdate} />
|
||||
} else if (type === 'training_type') {
|
||||
return <TrainingTypeDetail trainingType={item} onUpdate={onUpdate} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2>{focusArea.icon} {focusArea.name}</h2>
|
||||
{focusArea.description && <p style={{ color: 'var(--text2)' }}>{focusArea.description}</p>}
|
||||
<button className="btn btn-primary" onClick={() => setEditing(true)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Fokusbereich bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Icon</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={form.icon}
|
||||
onChange={e => setForm({ ...form, icon: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditing(false)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDirectionDetail({ styleDirection, onUpdate }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>{styleDirection.name}</h2>
|
||||
{styleDirection.abbreviation && <p style={{ color: 'var(--text2)' }}>Kürzel: {styleDirection.abbreviation}</p>}
|
||||
{styleDirection.description && <p style={{ color: 'var(--text2)' }}>{styleDirection.description}</p>}
|
||||
|
||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h3>Zugeordnete Zielgruppen</h3>
|
||||
<ul>
|
||||
{styleDirection.target_groups.map(tg => (
|
||||
<li key={tg.id}>
|
||||
{tg.name} {tg.is_primary && <span style={{ color: 'var(--accent)' }}>★ Primär</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn-primary" style={{ marginTop: '16px' }}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingTypeDetail({ trainingType, onUpdate }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>{trainingType.name}</h2>
|
||||
{trainingType.abbreviation && <p style={{ color: 'var(--text2)' }}>Kürzel: {trainingType.abbreviation}</p>}
|
||||
{trainingType.description && <p style={{ color: 'var(--text2)' }}>{trainingType.description}</p>}
|
||||
|
||||
<button className="btn btn-primary" style={{ marginTop: '16px' }}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminHierarchyPage
|
||||
|
|
@ -257,6 +257,11 @@ export async function deleteFocusArea(id) {
|
|||
return request(`/api/focus-areas/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Admin Hierarchy (Tree View)
|
||||
export async function getAdminHierarchy() {
|
||||
return request('/api/admin/hierarchy')
|
||||
}
|
||||
|
||||
// Style Directions (formerly Training Styles)
|
||||
export async function listStyleDirections(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user