Large AdminHierarchyPage.jsx (1200+ lines) causes ESBuild parser error. Using simplified version temporarily while investigating the issue. Broken version saved as AdminHierarchyPage.broken.jsx for debugging. Will split into modules and restore full functionality.
1236 lines
39 KiB
JavaScript
1236 lines
39 KiB
JavaScript
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 [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(() => {
|
|
if (activeTab === 'hierarchy') {
|
|
loadHierarchy()
|
|
} else if (activeTab === 'catalogs') {
|
|
loadCatalogs()
|
|
} else if (activeTab === 'assignments') {
|
|
loadAssignments()
|
|
}
|
|
}, [activeTab])
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)) {
|
|
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 (
|
|
<>
|
|
<style>{`
|
|
.admin-hierarchy-container {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
padding-bottom: 80px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.admin-hierarchy-container {
|
|
grid-template-columns: 400px 1fr;
|
|
}
|
|
|
|
.admin-tree-view {
|
|
display: block !important;
|
|
}
|
|
|
|
.admin-back-button {
|
|
display: none !important;
|
|
}
|
|
}
|
|
|
|
.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);
|
|
}
|
|
`}</style>
|
|
|
|
<div style={{ padding: '16px', paddingBottom: '80px' }}>
|
|
{/* Tabs */}
|
|
<div className="admin-tabs">
|
|
<button
|
|
className={`admin-tab ${activeTab === 'hierarchy' ? 'active' : ''}`}
|
|
onClick={() => { setActiveTab('hierarchy'); setSelectedItem(null) }}
|
|
>
|
|
Hierarchie
|
|
</button>
|
|
<button
|
|
className={`admin-tab ${activeTab === 'catalogs' ? 'active' : ''}`}
|
|
onClick={() => { setActiveTab('catalogs'); setSelectedItem(null) }}
|
|
>
|
|
Kataloge
|
|
</button>
|
|
<button
|
|
className={`admin-tab ${activeTab === 'assignments' ? 'active' : ''}`}
|
|
onClick={() => { setActiveTab('assignments'); setSelectedItem(null) }}
|
|
>
|
|
Zuordnungen
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'hierarchy' && (
|
|
<HierarchyTab
|
|
hierarchy={hierarchy}
|
|
expandedNodes={expandedNodes}
|
|
selectedItem={selectedItem}
|
|
loading={loading}
|
|
error={error}
|
|
onToggleNode={(nodeId) => {
|
|
const newExpanded = new Set(expandedNodes)
|
|
if (newExpanded.has(nodeId)) {
|
|
newExpanded.delete(nodeId)
|
|
} else {
|
|
newExpanded.add(nodeId)
|
|
}
|
|
setExpandedNodes(newExpanded)
|
|
}}
|
|
onSelectItem={setSelectedItem}
|
|
onUpdate={loadHierarchy}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'catalogs' && (
|
|
<CatalogsTab
|
|
targetGroups={targetGroups}
|
|
skillCategories={skillCategories}
|
|
trainingCharacters={trainingCharacters}
|
|
loading={loading}
|
|
error={error}
|
|
onUpdate={loadCatalogs}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'assignments' && (
|
|
<AssignmentsTab
|
|
styleDirections={styleDirections}
|
|
targetGroups={targetGroups}
|
|
assignments={assignments}
|
|
loading={loading}
|
|
error={error}
|
|
onUpdate={loadAssignments}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tab 1: Hierarchy
|
|
// ============================================================================
|
|
|
|
function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) {
|
|
if (loading && hierarchy.length === 0) {
|
|
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
|
}
|
|
|
|
return (
|
|
<div className="admin-hierarchy-container">
|
|
{/* Tree View */}
|
|
<div
|
|
className="admin-tree-view"
|
|
style={{
|
|
display: selectedItem ? 'none' : 'block',
|
|
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>
|
|
|
|
{/* Detail Panel */}
|
|
{selectedItem && (
|
|
<div
|
|
style={{
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
background: 'var(--surface)'
|
|
}}
|
|
>
|
|
<button
|
|
className="btn admin-back-button"
|
|
onClick={() => onSelectItem(null)}
|
|
style={{ marginBottom: '16px' }}
|
|
>
|
|
← Zurück zur Übersicht
|
|
</button>
|
|
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tab 2: Global Catalogs
|
|
// ============================================================================
|
|
|
|
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
|
|
if (loading) {
|
|
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
|
|
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
|
|
|
|
<CatalogSection
|
|
title="Zielgruppen"
|
|
icon="🎯"
|
|
items={targetGroups}
|
|
onUpdate={onUpdate}
|
|
createFn={api.createTargetGroup}
|
|
updateFn={api.updateTargetGroup}
|
|
deleteFn={api.deleteTargetGroup}
|
|
fields={[
|
|
{ key: 'name', label: 'Name', type: 'text', required: true },
|
|
{ key: 'description', label: 'Beschreibung', type: 'textarea' },
|
|
{ key: 'min_age', label: 'Min. Alter', type: 'number' },
|
|
{ key: 'max_age', label: 'Max. Alter', type: 'number' }
|
|
]}
|
|
/>
|
|
|
|
<CatalogSection
|
|
title="Fähigkeitskategorien"
|
|
icon="⚡"
|
|
items={skillCategories}
|
|
onUpdate={onUpdate}
|
|
createFn={api.createSkillCategory}
|
|
updateFn={api.updateSkillCategory}
|
|
deleteFn={api.deleteSkillCategory}
|
|
fields={[
|
|
{ key: 'name', label: 'Name', type: 'text', required: true },
|
|
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
|
]}
|
|
/>
|
|
|
|
<CatalogSection
|
|
title="Trainingscharakter"
|
|
icon="💪"
|
|
items={trainingCharacters}
|
|
onUpdate={onUpdate}
|
|
createFn={api.createTrainingCharacter}
|
|
updateFn={api.updateTrainingCharacter}
|
|
deleteFn={api.deleteTrainingCharacter}
|
|
fields={[
|
|
{ key: 'name', label: 'Name', type: 'text', required: true },
|
|
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
|
]}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tab 3: Assignments Matrix
|
|
// ============================================================================
|
|
|
|
function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, error, onUpdate }) {
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
if (loading) {
|
|
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
|
|
|
|
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px', overflowX: 'auto' }}>
|
|
<h2 style={{ marginTop: 0 }}>Stilrichtungen ↔ Zielgruppen</h2>
|
|
<p style={{ color: 'var(--text2)', marginBottom: '24px' }}>
|
|
Ordne Zielgruppen den Stilrichtungen zu (mehrere möglich)
|
|
</p>
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '600px' }}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ textAlign: 'left', padding: '12px', borderBottom: '2px solid var(--border)' }}>
|
|
Stilrichtung
|
|
</th>
|
|
{targetGroups.map(tg => (
|
|
<th key={tg.id} style={{ textAlign: 'center', padding: '12px', borderBottom: '2px solid var(--border)', minWidth: '100px' }}>
|
|
{tg.name}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Object.entries(groupedStyles).map(([focusArea, styles]) => (
|
|
<React.Fragment key={focusArea}>
|
|
<tr>
|
|
<td colSpan={targetGroups.length + 1} style={{ padding: '16px 12px 8px', fontWeight: 600, fontSize: '14px', color: 'var(--text2)', textTransform: 'uppercase' }}>
|
|
{focusArea}
|
|
</td>
|
|
</tr>
|
|
{styles.map(sd => (
|
|
<tr key={sd.id}>
|
|
<td style={{ padding: '12px', borderBottom: '1px solid var(--border)' }}>
|
|
{sd.name}
|
|
{sd.abbreviation && <span style={{ color: 'var(--text3)', marginLeft: '8px' }}>({sd.abbreviation})</span>}
|
|
</td>
|
|
{targetGroups.map(tg => (
|
|
<td key={tg.id} style={{ textAlign: 'center', padding: '12px', borderBottom: '1px solid var(--border)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isAssigned(sd.id, tg.id)}
|
|
onChange={() => toggleAssignment(sd.id, tg.id)}
|
|
disabled={saving}
|
|
style={{ cursor: 'pointer', width: '20px', height: '20px' }}
|
|
/>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{styleDirections.length === 0 && (
|
|
<div style={{ textAlign: 'center', color: 'var(--text2)', padding: '40px' }}>
|
|
Keine Stilrichtungen vorhanden. Erstelle zuerst Stilrichtungen in der Hierarchie.
|
|
</div>
|
|
)}
|
|
|
|
{targetGroups.length === 0 && (
|
|
<div style={{ textAlign: 'center', color: 'var(--text2)', padding: '40px' }}>
|
|
Keine Zielgruppen vorhanden. Erstelle zuerst Zielgruppen im Kataloge-Tab.
|
|
</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 */}
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span>Stilrichtungen</span>
|
|
<button
|
|
className="btn"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
|
|
}}
|
|
>
|
|
+ Neu
|
|
</button>
|
|
</div>
|
|
{focusArea.style_directions && focusArea.style_directions.map(sd => (
|
|
<StyleDirectionNode
|
|
key={sd.id}
|
|
styleDirection={sd}
|
|
onSelect={onSelect}
|
|
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Training Types Section */}
|
|
<div>
|
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span>Trainingstypen</span>
|
|
<button
|
|
className="btn"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
|
|
}}
|
|
>
|
|
+ Neu
|
|
</button>
|
|
</div>
|
|
{focusArea.training_types && 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, focusAreas }) {
|
|
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} focusAreas={focusAreas} />
|
|
} else if (type === 'training_type') {
|
|
return <TrainingTypeDetail trainingType={item} onUpdate={onUpdate} focusAreas={focusAreas} />
|
|
} else if (type === 'create_style_direction') {
|
|
return <CreateStyleDirectionForm context={item} onUpdate={onUpdate} />
|
|
} else if (type === 'create_training_type') {
|
|
return <CreateTrainingTypeForm context={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, focusAreas }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [form, setForm] = useState({
|
|
name: styleDirection.name,
|
|
abbreviation: styleDirection.abbreviation || '',
|
|
description: styleDirection.description || '',
|
|
focus_area_id: styleDirection.focus_area_id
|
|
})
|
|
|
|
async function handleSave() {
|
|
try {
|
|
await api.updateStyleDirection(styleDirection.id, form)
|
|
setEditing(false)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!confirm(`Stilrichtung "${styleDirection.name}" wirklich löschen?`)) return
|
|
try {
|
|
await api.deleteStyleDirection(styleDirection.id)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
if (!editing) {
|
|
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>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
|
<button className="btn btn-primary" onClick={() => setEditing(true)}>Bearbeiten</button>
|
|
<button className="btn" onClick={handleDelete}>Löschen</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h2>Stilrichtung 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">Kürzel</label>
|
|
<input
|
|
className="form-input"
|
|
value={form.abbreviation}
|
|
onChange={e => setForm({ ...form, abbreviation: 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 className="form-row">
|
|
<label className="form-label">Fokusbereich (Zuordnung ändern)</label>
|
|
<select
|
|
className="form-input"
|
|
value={form.focus_area_id || ''}
|
|
onChange={e => setForm({ ...form, focus_area_id: parseInt(e.target.value) })}
|
|
>
|
|
<option value="">- Fokusbereich auswählen -</option>
|
|
{focusAreas && focusAreas.map(fa => (
|
|
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
|
))}
|
|
</select>
|
|
</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 TrainingTypeDetail({ trainingType, onUpdate, focusAreas }) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [form, setForm] = useState({
|
|
name: trainingType.name,
|
|
abbreviation: trainingType.abbreviation || '',
|
|
description: trainingType.description || '',
|
|
focus_area_id: trainingType.focus_area_id
|
|
})
|
|
|
|
async function handleSave() {
|
|
try {
|
|
await api.updateTrainingType(trainingType.id, form)
|
|
setEditing(false)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!confirm(`Trainingstyp "${trainingType.name}" wirklich löschen?`)) return
|
|
try {
|
|
await api.deleteTrainingType(trainingType.id)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
if (!editing) {
|
|
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>}
|
|
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
|
<button className="btn btn-primary" onClick={() => setEditing(true)}>Bearbeiten</button>
|
|
<button className="btn" onClick={handleDelete}>Löschen</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h2>Trainingstyp 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">Kürzel</label>
|
|
<input
|
|
className="form-input"
|
|
value={form.abbreviation}
|
|
onChange={e => setForm({ ...form, abbreviation: 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 className="form-row">
|
|
<label className="form-label">Fokusbereich (Zuordnung ändern)</label>
|
|
<select
|
|
className="form-input"
|
|
value={form.focus_area_id || ''}
|
|
onChange={e => setForm({ ...form, focus_area_id: parseInt(e.target.value) })}
|
|
>
|
|
<option value="">- Fokusbereich auswählen -</option>
|
|
{focusAreas && focusAreas.map(fa => (
|
|
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
|
))}
|
|
</select>
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Create Forms
|
|
// ============================================================================
|
|
|
|
function CreateStyleDirectionForm({ context, onUpdate }) {
|
|
const [form, setForm] = useState({
|
|
name: '',
|
|
abbreviation: '',
|
|
description: '',
|
|
focus_area_id: context.focus_area_id
|
|
})
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function handleCreate() {
|
|
if (!form.name) {
|
|
alert('Name ist erforderlich')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
try {
|
|
await api.createStyleDirection(form)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h2>Neue Stilrichtung für {context.focus_area_name}</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 })}
|
|
placeholder="z.B. Shotokan"
|
|
/>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Kürzel</label>
|
|
<input
|
|
className="form-input"
|
|
value={form.abbreviation}
|
|
onChange={e => setForm({ ...form, abbreviation: e.target.value })}
|
|
placeholder="z.B. SKA"
|
|
/>
|
|
</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={handleCreate}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Erstelle...' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreateTrainingTypeForm({ context, onUpdate }) {
|
|
const [form, setForm] = useState({
|
|
name: '',
|
|
abbreviation: '',
|
|
description: '',
|
|
focus_area_id: context.focus_area_id
|
|
})
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function handleCreate() {
|
|
if (!form.name) {
|
|
alert('Name ist erforderlich')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
try {
|
|
await api.createTrainingType(form)
|
|
onUpdate()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h2>Neuer Trainingstyp für {context.focus_area_name}</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 })}
|
|
placeholder="z.B. Breitensport"
|
|
/>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Kürzel</label>
|
|
<input
|
|
className="form-input"
|
|
value={form.abbreviation}
|
|
onChange={e => setForm({ ...form, abbreviation: e.target.value })}
|
|
placeholder="z.B. BS"
|
|
/>
|
|
</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={handleCreate}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Erstelle...' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 (
|
|
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
|
|
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
|
|
</div>
|
|
|
|
{/* Create Form */}
|
|
{creating && (
|
|
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
|
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
|
|
{fields.map(field => (
|
|
<div key={field.key} className="form-row">
|
|
<label className="form-label">
|
|
{field.label} {field.required && '*'}
|
|
</label>
|
|
{field.type === 'textarea' ? (
|
|
<textarea
|
|
className="form-input"
|
|
value={form[field.key] || ''}
|
|
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="form-input"
|
|
type={field.type}
|
|
value={form[field.key] || ''}
|
|
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
|
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
|
|
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Items List */}
|
|
<div style={{ display: 'grid', gap: '12px' }}>
|
|
{items.map(item => (
|
|
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
|
{editing === item.id ? (
|
|
// Edit Mode
|
|
<div>
|
|
{fields.map(field => (
|
|
<div key={field.key} className="form-row">
|
|
<label className="form-label">{field.label}</label>
|
|
{field.type === 'textarea' ? (
|
|
<textarea
|
|
className="form-input"
|
|
value={form[field.key] || ''}
|
|
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="form-input"
|
|
type={field.type}
|
|
value={form[field.key] || ''}
|
|
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
|
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
|
|
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// View Mode
|
|
<div>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<strong>{item.name}</strong>
|
|
{item.min_age !== null && item.max_age !== null && (
|
|
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
|
|
Alter: {item.min_age}-{item.max_age}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.description && (
|
|
<p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>
|
|
)}
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
|
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
|
|
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{items.length === 0 && !creating && (
|
|
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
|
|
Noch keine Einträge vorhanden
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AdminHierarchyPage
|