2026-04-24 07:51:40 +02:00
|
|
|
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() {
|
2026-04-24 08:46:32 +02:00
|
|
|
const [activeTab, setActiveTab] = useState('hierarchy')
|
2026-04-24 07:51:40 +02:00
|
|
|
const [hierarchy, setHierarchy] = useState([])
|
|
|
|
|
const [expandedNodes, setExpandedNodes] = useState(new Set())
|
|
|
|
|
const [selectedItem, setSelectedItem] = useState(null)
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
2026-04-24 08:46:32 +02:00
|
|
|
// 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([])
|
|
|
|
|
|
2026-04-24 07:51:40 +02:00
|
|
|
useEffect(() => {
|
2026-04-24 08:46:32 +02:00
|
|
|
if (activeTab === 'hierarchy') {
|
|
|
|
|
loadHierarchy()
|
|
|
|
|
} else if (activeTab === 'catalogs') {
|
|
|
|
|
loadCatalogs()
|
|
|
|
|
} else if (activeTab === 'assignments') {
|
|
|
|
|
loadAssignments()
|
|
|
|
|
}
|
|
|
|
|
}, [activeTab])
|
2026-04-24 07:51:40 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:46:32 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 07:51:40 +02:00
|
|
|
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 (
|
2026-04-24 08:33:11 +02:00
|
|
|
<>
|
|
|
|
|
<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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:46:32 +02:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-04-24 08:33:11 +02:00
|
|
|
`}</style>
|
|
|
|
|
|
2026-04-24 08:46:32 +02:00
|
|
|
<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">
|
2026-04-24 08:33:11 +02:00
|
|
|
{/* Tree View */}
|
|
|
|
|
<div
|
|
|
|
|
className="admin-tree-view"
|
|
|
|
|
style={{
|
2026-04-24 08:52:12 +02:00
|
|
|
display: selectedItem ? 'none' : 'block',
|
2026-04-24 08:33:11 +02:00
|
|
|
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>
|
2026-04-24 07:51:40 +02:00
|
|
|
|
2026-04-24 08:33:11 +02:00
|
|
|
{/* Detail Panel */}
|
|
|
|
|
{selectedItem && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
borderRadius: '12px',
|
|
|
|
|
padding: '20px',
|
|
|
|
|
background: 'var(--surface)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className="btn admin-back-button"
|
2026-04-24 08:46:32 +02:00
|
|
|
onClick={() => onSelectItem(null)}
|
2026-04-24 08:33:11 +02:00
|
|
|
style={{ marginBottom: '16px' }}
|
|
|
|
|
>
|
|
|
|
|
← Zurück zur Übersicht
|
|
|
|
|
</button>
|
2026-04-24 08:46:32 +02:00
|
|
|
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-24 08:46:32 +02:00
|
|
|
</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>
|
2026-04-24 07:51:40 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// 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 */}
|
2026-04-24 08:39:48 +02:00
|
|
|
<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>
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
2026-04-24 08:39:48 +02:00
|
|
|
{focusArea.style_directions && focusArea.style_directions.map(sd => (
|
|
|
|
|
<StyleDirectionNode
|
|
|
|
|
key={sd.id}
|
|
|
|
|
styleDirection={sd}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-04-24 07:51:40 +02:00
|
|
|
|
|
|
|
|
{/* Training Types Section */}
|
2026-04-24 08:39:48 +02:00
|
|
|
<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>
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
2026-04-24 08:39:48 +02:00
|
|
|
{focusArea.training_types && focusArea.training_types.map(tt => (
|
|
|
|
|
<TrainingTypeNode
|
|
|
|
|
key={tt.id}
|
|
|
|
|
trainingType={tt}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-04-24 07:51:40 +02:00
|
|
|
</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)
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2026-04-24 08:39:48 +02:00
|
|
|
function DetailPanel({ item, onUpdate, focusAreas }) {
|
2026-04-24 07:51:40 +02:00
|
|
|
if (!item) return null
|
|
|
|
|
|
|
|
|
|
const type = item._type
|
|
|
|
|
|
|
|
|
|
if (type === 'focus_area') {
|
|
|
|
|
return <FocusAreaDetail focusArea={item} onUpdate={onUpdate} />
|
|
|
|
|
} else if (type === 'style_direction') {
|
2026-04-24 08:39:48 +02:00
|
|
|
return <StyleDirectionDetail styleDirection={item} onUpdate={onUpdate} focusAreas={focusAreas} />
|
2026-04-24 07:51:40 +02:00
|
|
|
} else if (type === 'training_type') {
|
2026-04-24 08:39:48 +02:00
|
|
|
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} />
|
2026-04-24 07:51:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:39:48 +02:00
|
|
|
function StyleDirectionDetail({ styleDirection, onUpdate, focusAreas }) {
|
2026-04-24 08:33:11 +02:00
|
|
|
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
|
|
|
|
|
})
|
2026-04-24 07:51:40 +02:00
|
|
|
|
2026-04-24 08:33:11 +02:00
|
|
|
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>
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
2026-04-24 08:33:11 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-24 07:51:40 +02:00
|
|
|
|
2026-04-24 08:33:11 +02:00
|
|
|
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>
|
2026-04-24 08:39:48 +02:00
|
|
|
<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>
|
2026-04-24 08:33:11 +02:00
|
|
|
<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>
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:39:48 +02:00
|
|
|
function TrainingTypeDetail({ trainingType, onUpdate, focusAreas }) {
|
2026-04-24 08:33:11 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 07:51:40 +02:00
|
|
|
return (
|
|
|
|
|
<div>
|
2026-04-24 08:33:11 +02:00
|
|
|
<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>
|
2026-04-24 08:39:48 +02:00
|
|
|
<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>
|
2026-04-24 08:33:11 +02:00
|
|
|
<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>
|
2026-04-24 07:51:40 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:39:48 +02:00
|
|
|
// ============================================================================
|
|
|
|
|
// 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 08:46:32 +02:00
|
|
|
// ============================================================================
|
|
|
|
|
// 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 07:51:40 +02:00
|
|
|
export default AdminHierarchyPage
|