shinkan-jinkendo/frontend/src/pages/AdminCatalogsPage.jsx
Lars c2d9eac151
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
feat: enhance API and profile management with environment configurations
- Added functions to determine production environment and OpenAPI exposure settings, improving API documentation control.
- Updated FastAPI initialization to conditionally set OpenAPI and documentation URLs based on environment variables.
- Refactored health check response to limit detail exposure in production environments, enhancing security.
- Streamlined profile management by removing legacy ID retrieval and ensuring session-based profile access for security improvements.
2026-05-07 10:40:10 +02:00

1259 lines
54 KiB
JavaScript

import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import PageSectionNav from '../components/PageSectionNav'
const CATALOG_SUBTABS = [
{ id: 'focus-areas', label: 'Fokusbereiche' },
{ id: 'training-styles', label: 'Stilrichtungen' },
{ id: 'training-types', label: 'Trainingsstil' },
{ id: 'hierarchy', label: 'Hierarchie' },
{ id: 'target-groups', label: 'Zielgruppen' },
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' },
]
export default function AdminCatalogsPage() {
const [activeTab, setActiveTab] = useState('focus-areas')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Focus Areas
const [focusAreas, setFocusAreas] = useState([])
const [editingFA, setEditingFA] = useState(null)
const [newFA, setNewFA] = useState({ name: '', description: '', color: '#1D9E75', icon: '' })
// Style Directions (Stilrichtungen)
const [trainingStyles, setTrainingStyles] = useState([])
const [editingTS, setEditingTS] = useState(null)
const [newTS, setNewTS] = useState({ name: '', description: '', focus_area_id: null })
// Training Characters
const [trainingCharacters, setTrainingCharacters] = useState([])
const [editingTC, setEditingTC] = useState(null)
const [newTC, setNewTC] = useState({ name: '', description: '' })
// Training Types (Breitensport, Leistungssport, etc.)
const [trainingTypes, setTrainingTypes] = useState([])
const [editingTT, setEditingTT] = useState(null)
const [newTT, setNewTT] = useState({ name: '', abbreviation: '', description: '', focus_area_id: null })
// Skill Categories
const [skillCategories, setSkillCategories] = useState([])
const [editingSC, setEditingSC] = useState(null)
const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: null })
// Target Groups (Global - unabhängig von Stilen)
const [targetGroups, setTargetGroups] = useState([])
const [editingTG, setEditingTG] = useState(null)
const [newTG, setNewTG] = useState({ name: '', description: '', min_age: null, max_age: null })
// Trainer Focus Areas
const [trainerAssignments, setTrainerAssignments] = useState([])
const [profiles, setProfiles] = useState([])
const [newAssignment, setNewAssignment] = useState({ profile_id: '', focus_area_id: '' })
// Hierarchy (Tree-View)
const [hierarchyData, setHierarchyData] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set())
// M:N Assignment Matrix
const [assignments, setAssignments] = useState([])
const [matrixLoading, setMatrixLoading] = useState(false)
useEffect(() => {
loadData()
}, [activeTab])
async function loadData() {
setLoading(true)
setError('')
try {
if (activeTab === 'focus-areas') {
const data = await api.listFocusAreas()
setFocusAreas(data)
} else if (activeTab === 'training-styles') {
const data = await api.listStyleDirections()
setTrainingStyles(data)
} else if (activeTab === 'training-characters') {
const data = await api.listTrainingCharacters()
setTrainingCharacters(data)
} else if (activeTab === 'training-types') {
const data = await api.listTrainingTypes()
setTrainingTypes(data)
} else if (activeTab === 'skill-categories') {
const data = await api.listSkillCategories()
setSkillCategories(data)
} else if (activeTab === 'target-groups') {
const groups = await api.listTargetGroups()
setTargetGroups(groups)
} else if (activeTab === 'trainer-assignments') {
const [assignments, profs, areas] = await Promise.all([
api.listTrainerFocusAreas(),
api.listProfiles(),
api.listFocusAreas()
])
setTrainerAssignments(assignments)
setProfiles(profs)
setFocusAreas(areas)
} else if (activeTab === 'hierarchy') {
const data = await api.getStyleDirectionsHierarchy()
setHierarchyData(data)
} else if (activeTab === 'target-groups-matrix') {
const [styles, groups, assigns] = await Promise.all([
api.listStyleDirections(),
api.listTargetGroups(),
api.listStyleDirectionTargetGroups()
])
setTrainingStyles(styles)
setTargetGroups(groups)
setAssignments(assigns)
}
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
// Focus Areas
async function createFocusArea() {
try {
await api.createFocusArea(newFA)
setNewFA({ name: '', description: '', color: '#1D9E75', icon: '' })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateFocusArea(id, data) {
try {
await api.updateFocusArea(id, data)
setEditingFA(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteFocusArea(id) {
if (!confirm('Fokusbereich wirklich löschen?')) return
try {
await api.deleteFocusArea(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Style Directions (formerly Training Styles)
async function createStyleDirection() {
try {
await api.createStyleDirection(newTS)
setNewTS({ name: '', description: '', focus_area_id: null })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateStyleDirection(id, data) {
try {
await api.updateStyleDirection(id, data)
setEditingTS(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteStyleDirection(id) {
if (!confirm('Stilrichtung wirklich löschen?')) return
try {
await api.deleteStyleDirection(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Training Characters
async function createTrainingCharacter() {
try {
await api.createTrainingCharacter(newTC)
setNewTC({ name: '', description: '' })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateTrainingCharacter(id, data) {
try {
await api.updateTrainingCharacter(id, data)
setEditingTC(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteTrainingCharacter(id) {
if (!confirm('Trainingscharakter wirklich löschen?')) return
try {
await api.deleteTrainingCharacter(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Training Types
async function createTrainingType() {
try {
await api.createTrainingType(newTT)
setNewTT({ name: '', abbreviation: '', description: '', focus_area_id: null })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateTrainingType(id, data) {
try {
await api.updateTrainingType(id, data)
setEditingTT(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteTrainingType(id) {
if (!confirm('Trainingsstil wirklich löschen?')) return
try {
await api.deleteTrainingType(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Skill Categories
async function createSkillCategory() {
try {
await api.createSkillCategory(newSC)
setNewSC({ name: '', description: '', parent_category_id: null })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateSkillCategory(id, data) {
try {
await api.updateSkillCategory(id, data)
setEditingSC(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteSkillCategory(id) {
if (!confirm('Fähigkeitskategorie wirklich löschen?')) return
try {
await api.deleteSkillCategory(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Target Groups
async function createTargetGroup() {
try {
await api.createTargetGroup(newTG)
setNewTG({ name: '', description: '', min_age: null, max_age: null })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateTargetGroup(id, data) {
try {
await api.updateTargetGroup(id, data)
setEditingTG(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteTargetGroup(id) {
if (!confirm('Zielgruppe wirklich löschen?')) return
try {
await api.deleteTargetGroup(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Trainer Assignments
async function assignTrainer() {
try {
await api.assignTrainerFocusArea(newAssignment)
setNewAssignment({ profile_id: '', focus_area_id: '' })
loadData()
} catch (e) {
setError(e.message)
}
}
async function removeAssignment(id) {
if (!confirm('Zuordnung wirklich entfernen?')) return
try {
await api.deleteTrainerFocusArea(id)
loadData()
} catch (e) {
setError(e.message)
}
}
return (
<div className="app-page">
<AdminPageNav />
<h1 className="page-title">Stammdaten-Kataloge</h1>
<PageSectionNav
ariaLabel="Katalogbereiche"
value={activeTab}
onChange={setActiveTab}
items={CATALOG_SUBTABS}
/>
{error && <div className="admin-matrix-alert">{error}</div>}
{loading ? (
<div className="spinner" />
) : (
<>
{/* Focus Areas */}
{activeTab === 'focus-areas' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neuer Fokusbereich</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newFA.name}
onChange={e => setNewFA({ ...newFA, name: e.target.value })}
placeholder="z.B. Karate"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newFA.description}
onChange={e => setNewFA({ ...newFA, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Farbe</label>
<input
type="color"
className="form-input"
value={newFA.color}
onChange={e => setNewFA({ ...newFA, color: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Icon</label>
<input
className="form-input"
value={newFA.icon}
onChange={e => setNewFA({ ...newFA, icon: e.target.value })}
placeholder="z.B. 🥋"
/>
</div>
<button className="btn btn-primary" onClick={createFocusArea}>Anlegen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{focusAreas.map(fa => (
<div key={fa.id} className="card">
{editingFA?.id === fa.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingFA.name}
onChange={e => setEditingFA({ ...editingFA, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingFA.description || ''}
onChange={e => setEditingFA({ ...editingFA, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Farbe</label>
<input
type="color"
className="form-input"
value={editingFA.color}
onChange={e => setEditingFA({ ...editingFA, color: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Icon</label>
<input
className="form-input"
value={editingFA.icon || ''}
onChange={e => setEditingFA({ ...editingFA, icon: e.target.value })}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
</div>
</div>
) : (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<span style={{ fontSize: '24px' }}>{fa.icon}</span>
<div style={{ flex: 1 }}>
<h3 style={{ margin: 0 }}>{fa.name}</h3>
<p style={{ margin: '4px 0 0', color: 'var(--text2)', fontSize: '14px' }}>{fa.description}</p>
</div>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '4px',
background: fa.color
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Training Styles */}
{activeTab === 'training-styles' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neue Stilrichtung</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newTS.name}
onChange={e => setNewTS({ ...newTS, name: e.target.value })}
placeholder="z.B. Shotokan"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newTS.description}
onChange={e => setNewTS({ ...newTS, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={newTS.focus_area_id || ''}
onChange={e => setNewTS({ ...newTS, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={createStyleDirection}>Anlegen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{trainingStyles.map(ts => (
<div key={ts.id} className="card">
{editingTS?.id === ts.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingTS.name}
onChange={e => setEditingTS({ ...editingTS, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingTS.description || ''}
onChange={e => setEditingTS({ ...editingTS, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={editingTS.focus_area_id || ''}
onChange={e => setEditingTS({ ...editingTS, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
</div>
</div>
) : (
<div>
<h3>{ts.name}</h3>
{ts.focus_area_name && (
<p style={{ margin: '4px 0', color: 'var(--accent)', fontSize: '14px', fontWeight: 500 }}>
{ts.focus_area_icon} {ts.focus_area_name}
</p>
)}
{ts.parent_style_name && (
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
Untergeordnet: {ts.parent_style_name}
</p>
)}
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Training Characters */}
{activeTab === 'training-characters' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neuer Trainingscharakter</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newTC.name}
onChange={e => setNewTC({ ...newTC, name: e.target.value })}
placeholder="z.B. Technisch"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newTC.description}
onChange={e => setNewTC({ ...newTC, description: e.target.value })}
rows={3}
/>
</div>
<button className="btn btn-primary" onClick={createTrainingCharacter}>Anlegen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{trainingCharacters.map(tc => (
<div key={tc.id} className="card">
{editingTC?.id === tc.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingTC.name}
onChange={e => setEditingTC({ ...editingTC, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingTC.description || ''}
onChange={e => setEditingTC({ ...editingTC, description: e.target.value })}
rows={3}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateTrainingCharacter(tc.id, editingTC)}>Speichern</button>
<button className="btn" onClick={() => setEditingTC(null)}>Abbrechen</button>
</div>
</div>
) : (
<div>
<h3>{tc.name}</h3>
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{tc.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTC(tc)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteTrainingCharacter(tc.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Training Types */}
{activeTab === 'training-types' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neuer Trainingsstil</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newTT.name}
onChange={e => setNewTT({ ...newTT, name: e.target.value })}
placeholder="z.B. Breitensport"
/>
</div>
<div className="form-row">
<label className="form-label">Kürzel (optional)</label>
<input
className="form-input"
value={newTT.abbreviation || ''}
onChange={e => setNewTT({ ...newTT, abbreviation: e.target.value })}
placeholder="z.B. BS"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newTT.description || ''}
onChange={e => setNewTT({ ...newTT, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={newTT.focus_area_id || ''}
onChange={e => setNewTT({ ...newTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={createTrainingType}>Anlegen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{trainingTypes.map(tt => (
<div key={tt.id} className="card">
{editingTT?.id === tt.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingTT.name}
onChange={e => setEditingTT({ ...editingTT, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Kürzel</label>
<input
className="form-input"
value={editingTT.abbreviation || ''}
onChange={e => setEditingTT({ ...editingTT, abbreviation: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingTT.description || ''}
onChange={e => setEditingTT({ ...editingTT, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={editingTT.focus_area_id || ''}
onChange={e => setEditingTT({ ...editingTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
</div>
</div>
) : (
<div>
<h3>
{tt.name}
{tt.abbreviation && <span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text2)' }}>({tt.abbreviation})</span>}
</h3>
{tt.description && <p style={{ margin: '8px 0', color: 'var(--text2)' }}>{tt.description}</p>}
{tt.focus_area_name && (
<p style={{ margin: '8px 0', fontSize: '14px' }}>
<span style={{ color: 'var(--text2)' }}>Fokusbereich: </span>
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
</p>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Skill Categories */}
{activeTab === 'skill-categories' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neue Fähigkeitskategorie</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newSC.name}
onChange={e => setNewSC({ ...newSC, name: e.target.value })}
placeholder="z.B. Technik"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newSC.description}
onChange={e => setNewSC({ ...newSC, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Übergeordnete Kategorie (optional)</label>
<select
className="form-input"
value={newSC.parent_category_id || ''}
onChange={e => setNewSC({ ...newSC, parent_category_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Keine -</option>
{skillCategories.map(sc => (
<option key={sc.id} value={sc.id}>{sc.name}</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={createSkillCategory}>Anlegen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{skillCategories.map(sc => (
<div key={sc.id} className="card">
{editingSC?.id === sc.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingSC.name}
onChange={e => setEditingSC({ ...editingSC, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingSC.description || ''}
onChange={e => setEditingSC({ ...editingSC, description: e.target.value })}
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Übergeordnete Kategorie</label>
<select
className="form-input"
value={editingSC.parent_category_id || ''}
onChange={e => setEditingSC({ ...editingSC, parent_category_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Keine -</option>
{skillCategories.filter(x => x.id !== sc.id).map(x => (
<option key={x.id} value={x.id}>{x.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateSkillCategory(sc.id, editingSC)}>Speichern</button>
<button className="btn" onClick={() => setEditingSC(null)}>Abbrechen</button>
</div>
</div>
) : (
<div>
<h3>{sc.name}</h3>
{sc.parent_category_name && (
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
{sc.parent_category_name}
</p>
)}
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{sc.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingSC(sc)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteSkillCategory(sc.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Target Groups */}
{activeTab === 'target-groups' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neue Zielgruppe (Global)</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newTG.name}
onChange={e => setNewTG({ ...newTG, name: e.target.value })}
placeholder="z.B. Breitensportler"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung (optional)</label>
<textarea
className="form-input"
value={newTG.description || ''}
onChange={e => setNewTG({ ...newTG, description: e.target.value })}
rows="3"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-row">
<label className="form-label">Min. Alter (optional)</label>
<input
type="number"
className="form-input"
value={newTG.min_age || ''}
onChange={e => setNewTG({ ...newTG, min_age: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
<div className="form-row">
<label className="form-label">Max. Alter (optional)</label>
<input
type="number"
className="form-input"
value={newTG.max_age || ''}
onChange={e => setNewTG({ ...newTG, max_age: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
</div>
<button className="btn btn-primary" onClick={createTargetGroup}>Erstellen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{targetGroups.map(tg => (
<div key={tg.id} className="card">
{editingTG === tg.id ? (
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={tg.name}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, name: e.target.value } : x
))}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={tg.description || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, description: e.target.value } : x
))}
rows="3"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-row">
<label className="form-label">Min. Alter</label>
<input
type="number"
className="form-input"
value={tg.min_age || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, min_age: e.target.value ? parseInt(e.target.value) : null } : x
))}
/>
</div>
<div className="form-row">
<label className="form-label">Max. Alter</label>
<input
type="number"
className="form-input"
value={tg.max_age || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, max_age: e.target.value ? parseInt(e.target.value) : null } : x
))}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h3 style={{ margin: 0 }}>{tg.name}</h3>
{(tg.min_age || tg.max_age) && (
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
Alter: {tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre
</p>
)}
{tg.description && (
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Trainer Assignments */}
{activeTab === 'trainer-assignments' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neue Zuordnung</h3>
<div className="form-row">
<label className="form-label">Trainer</label>
<select
className="form-input"
value={newAssignment.profile_id}
onChange={e => setNewAssignment({ ...newAssignment, profile_id: parseInt(e.target.value) })}
>
<option value="">- Trainer auswählen -</option>
{profiles.filter(p => p.role === 'trainer' || p.role === 'admin').map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.email})</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={newAssignment.focus_area_id}
onChange={e => setNewAssignment({ ...newAssignment, focus_area_id: parseInt(e.target.value) })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={assignTrainer}>Zuordnen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{trainerAssignments.map(ta => (
<div key={ta.id} className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h3 style={{ margin: 0 }}>{ta.trainer_name}</h3>
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
{ta.focus_area_icon} {ta.focus_area_name}
</p>
</div>
<button className="btn" onClick={() => removeAssignment(ta.id)}>Entfernen</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Hierarchy Tree-View */}
{activeTab === 'hierarchy' && (
<div>
<div className="card" style={{ marginBottom: '16px', background: 'var(--surface2)' }}>
<h3 style={{ margin: 0, marginBottom: '8px' }}>Fokusbereich Trainingsstil Zielgruppen</h3>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text2)' }}>
Hierarchische Ansicht der Katalog-Struktur. Zuordnungen verwalten Sie im Tab "Zuordnungen".
</p>
</div>
{hierarchyData.map(fa => (
<div key={fa.id} className="card" style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
padding: '4px 0'
}}
onClick={() => {
const newExpanded = new Set(expandedNodes)
if (newExpanded.has(`fa-${fa.id}`)) {
newExpanded.delete(`fa-${fa.id}`)
} else {
newExpanded.add(`fa-${fa.id}`)
}
setExpandedNodes(newExpanded)
}}
>
<span style={{ fontSize: '16px' }}>
{expandedNodes.has(`fa-${fa.id}`) ? '▼' : '▶'}
</span>
<span style={{ fontSize: '24px' }}>{fa.icon || '📁'}</span>
<div>
<h3 style={{ margin: 0 }}>{fa.name}</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '14px', color: 'var(--text2)' }}>
{fa.style_directions?.length || 0} Trainingsstil(e)
</p>
</div>
</div>
{expandedNodes.has(`fa-${fa.id}`) && fa.style_directions && fa.style_directions.length > 0 && (
<div style={{ marginLeft: '40px', marginTop: '12px', borderLeft: '2px solid var(--border)', paddingLeft: '16px' }}>
{fa.style_directions.map(ts => (
<div key={ts.id} style={{ marginBottom: '12px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
padding: '4px 0'
}}
onClick={(e) => {
e.stopPropagation()
const newExpanded = new Set(expandedNodes)
if (newExpanded.has(`ts-${ts.id}`)) {
newExpanded.delete(`ts-${ts.id}`)
} else {
newExpanded.add(`ts-${ts.id}`)
}
setExpandedNodes(newExpanded)
}}
>
<span style={{ fontSize: '14px' }}>
{expandedNodes.has(`ts-${ts.id}`) ? '▼' : '▶'}
</span>
<div>
<h4 style={{ margin: 0 }}>{ts.name}</h4>
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: 'var(--text2)' }}>
{ts.target_groups?.length || 0} Zielgruppe(n)
</p>
</div>
</div>
{expandedNodes.has(`ts-${ts.id}`) && ts.target_groups && ts.target_groups.length > 0 && (
<div style={{ marginLeft: '30px', marginTop: '8px' }}>
{ts.target_groups.map(tg => (
<div
key={tg.id}
style={{
padding: '8px 12px',
background: 'var(--surface)',
borderRadius: '6px',
marginBottom: '6px',
border: tg.is_primary ? '2px solid var(--accent)' : '1px solid var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: tg.is_primary ? 600 : 400 }}>
{tg.name}
{tg.is_primary && <span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--accent)' }}></span>}
</span>
{(tg.min_age || tg.max_age) && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre
</span>
)}
</div>
{tg.description && (
<p style={{ margin: '4px 0 0 0', fontSize: '12px', color: 'var(--text2)' }}>
{tg.description}
</p>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* M:N Assignment Matrix */}
{activeTab === 'target-groups-matrix' && (
<div>
<div className="card" style={{ marginBottom: '16px', background: 'var(--surface2)' }}>
<h3 style={{ margin: 0, marginBottom: '8px' }}>Zielgruppen-Zuordnungsmatrix</h3>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text2)' }}>
Ordnen Sie Zielgruppen den Trainingsstilen zu. Eine Zielgruppe kann mehreren Stilen zugeordnet sein.
</p>
</div>
{trainingStyles.length > 0 && targetGroups.length > 0 ? (
<div className="card" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '12px 8px', fontWeight: 600 }}>
Trainingsstil
</th>
{targetGroups.map(tg => (
<th
key={tg.id}
style={{
textAlign: 'center',
padding: '12px 8px',
fontWeight: 600,
minWidth: '100px'
}}
>
{tg.name}
</th>
))}
</tr>
</thead>
<tbody>
{trainingStyles.map(ts => (
<tr key={ts.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '12px 8px' }}>
<div>
<strong>{ts.name}</strong>
{ts.focus_area_name && (
<div style={{ fontSize: '12px', color: 'var(--text2)' }}>
{ts.focus_area_name}
</div>
)}
</div>
</td>
{targetGroups.map(tg => {
const assignment = assignments.find(
a => a.style_direction_id === ts.id && a.target_group_id === tg.id
)
const isAssigned = !!assignment
return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px 8px' }}>
<input
type="checkbox"
checked={isAssigned}
onChange={async () => {
try {
if (isAssigned) {
await api.deleteStyleDirectionTargetGroup(assignment.id)
} else {
await api.createStyleDirectionTargetGroup({
style_direction_id: ts.id,
target_group_id: tg.id,
is_primary: false
})
}
loadData()
} catch (e) {
setError(e.message)
}
}}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
{isAssigned && assignment.is_primary && (
<span style={{ marginLeft: '4px', color: 'var(--accent)' }}></span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="card">
<p style={{ margin: 0, color: 'var(--text2)' }}>
Keine Daten verfügbar. Bitte erstellen Sie zuerst Trainingsstile und Zielgruppen.
</p>
</div>
)}
</div>
)}
</>
)}
</div>
)
}