376 lines
12 KiB
React
376 lines
12 KiB
React
|
|
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
|