Backend: - Created routers/skills.py with full CRUD - Skills: list (with category filter), get, create, update, delete - Methods: list (with category filter), get, create, update, delete - Default: only show active items - Read access: all authenticated users - Write access: admin only - Registered skills router in main.py Frontend: - Complete SkillsPage with 2 tabs (Fähigkeiten, Trainingsmethoden) - Browse by category with cards layout - Admin CRUD forms (importance rating for skills, duration/group size for methods) - Mobile-responsive grid layout - Updated api.js with all skill/method functions - Added /skills route to App.jsx Migration already exists: 003_catalogs.sql (skills, training_methods + seed data) Next: Training Planning (core feature)
518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
||
import api from '../utils/api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
function SkillsPage() {
|
||
const { user } = useAuth()
|
||
const [activeTab, setActiveTab] = useState('skills')
|
||
const [skills, setSkills] = useState([])
|
||
const [methods, setMethods] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [editing, setEditing] = useState(null)
|
||
const [modalType, setModalType] = useState('skill')
|
||
const [formData, setFormData] = useState({})
|
||
|
||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const [skillsData, methodsData] = await Promise.all([
|
||
api.listSkills(),
|
||
api.listMethods()
|
||
])
|
||
setSkills(skillsData)
|
||
setMethods(methodsData)
|
||
} catch (err) {
|
||
console.error('Failed to load data:', err)
|
||
alert('Fehler beim Laden: ' + err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreate = (type) => {
|
||
setEditing(null)
|
||
setModalType(type)
|
||
|
||
if (type === 'skill') {
|
||
setFormData({
|
||
name: '',
|
||
category: '',
|
||
description: '',
|
||
importance: 3,
|
||
keywords: [],
|
||
status: 'active'
|
||
})
|
||
} else {
|
||
setFormData({
|
||
name: '',
|
||
abbreviation: '',
|
||
category: '',
|
||
description: '',
|
||
typical_duration: '',
|
||
typical_group_size: '',
|
||
related_skills: [],
|
||
keywords: [],
|
||
status: 'active'
|
||
})
|
||
}
|
||
|
||
setShowModal(true)
|
||
}
|
||
|
||
const handleEdit = (item, type) => {
|
||
setEditing(item)
|
||
setModalType(type)
|
||
setFormData({ ...item })
|
||
setShowModal(true)
|
||
}
|
||
|
||
const handleDelete = async (item, type) => {
|
||
const confirmMsg = type === 'skill'
|
||
? `Fähigkeit "${item.name}" wirklich löschen?`
|
||
: `Trainingsmethode "${item.name}" wirklich löschen?`
|
||
|
||
if (!confirm(confirmMsg)) return
|
||
|
||
try {
|
||
if (type === 'skill') {
|
||
await api.deleteSkill(item.id)
|
||
} else {
|
||
await api.deleteMethod(item.id)
|
||
}
|
||
await loadData()
|
||
} catch (err) {
|
||
alert('Fehler beim Löschen: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
|
||
try {
|
||
if (modalType === 'skill') {
|
||
if (editing) {
|
||
await api.updateSkill(editing.id, formData)
|
||
} else {
|
||
await api.createSkill(formData)
|
||
}
|
||
} else {
|
||
if (editing) {
|
||
await api.updateMethod(editing.id, formData)
|
||
} else {
|
||
await api.createMethod(formData)
|
||
}
|
||
}
|
||
|
||
setShowModal(false)
|
||
await loadData()
|
||
} catch (err) {
|
||
alert('Fehler beim Speichern: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const updateFormField = (field, value) => {
|
||
setFormData(prev => ({ ...prev, [field]: value }))
|
||
}
|
||
|
||
const groupByCategory = (items) => {
|
||
const grouped = {}
|
||
items.forEach(item => {
|
||
const cat = item.category || 'Ohne Kategorie'
|
||
if (!grouped[cat]) grouped[cat] = []
|
||
grouped[cat].push(item)
|
||
})
|
||
return grouped
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||
<div className="spinner"></div>
|
||
<p>Laden...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const skillsByCategory = groupByCategory(skills)
|
||
const methodsByCategory = groupByCategory(methods)
|
||
|
||
return (
|
||
<div style={{ padding: '2rem' }}>
|
||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
|
||
|
||
{/* Tabs */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '0.5rem',
|
||
marginBottom: '1.5rem',
|
||
borderBottom: '2px solid var(--border)'
|
||
}}>
|
||
{['skills', 'methods'].map(tab => (
|
||
<button
|
||
key={tab}
|
||
onClick={() => setActiveTab(tab)}
|
||
style={{
|
||
padding: '0.75rem 1.5rem',
|
||
background: activeTab === tab ? 'var(--accent)' : 'transparent',
|
||
color: activeTab === tab ? 'white' : 'var(--text1)',
|
||
border: 'none',
|
||
borderRadius: '8px 8px 0 0',
|
||
cursor: 'pointer',
|
||
fontWeight: activeTab === tab ? 'bold' : 'normal'
|
||
}}
|
||
>
|
||
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Skills Tab */}
|
||
{activeTab === 'skills' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||
<p style={{ color: 'var(--text2)' }}>
|
||
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
|
||
</p>
|
||
{isAdmin && (
|
||
<button className="btn btn-primary" onClick={() => handleCreate('skill')}>
|
||
+ Neue Fähigkeit
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{Object.keys(skillsByCategory).length === 0 ? (
|
||
<div className="card">
|
||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||
Keine Fähigkeiten gefunden
|
||
</p>
|
||
</div>
|
||
) : (
|
||
Object.keys(skillsByCategory).sort().map(category => (
|
||
<div key={category} style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
||
{category}
|
||
</h2>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||
gap: '1rem'
|
||
}}>
|
||
{skillsByCategory[category].map(skill => (
|
||
<div key={skill.id} className="card">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
|
||
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
|
||
{skill.importance && (
|
||
<span style={{
|
||
fontSize: '0.875rem',
|
||
padding: '0.25rem 0.5rem',
|
||
borderRadius: '4px',
|
||
background: 'var(--accent)',
|
||
color: 'white'
|
||
}}>
|
||
⭐ {skill.importance}/5
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{skill.description && (
|
||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||
{skill.description}
|
||
</p>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||
<button
|
||
className="btn btn-secondary"
|
||
style={{ flex: 1 }}
|
||
onClick={() => handleEdit(skill, 'skill')}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
className="btn"
|
||
style={{
|
||
background: 'var(--danger)',
|
||
color: 'white',
|
||
border: 'none'
|
||
}}
|
||
onClick={() => handleDelete(skill, 'skill')}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Methods Tab */}
|
||
{activeTab === 'methods' && (
|
||
<>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||
<p style={{ color: 'var(--text2)' }}>
|
||
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
|
||
</p>
|
||
{isAdmin && (
|
||
<button className="btn btn-primary" onClick={() => handleCreate('method')}>
|
||
+ Neue Methode
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{Object.keys(methodsByCategory).length === 0 ? (
|
||
<div className="card">
|
||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||
Keine Trainingsmethoden gefunden
|
||
</p>
|
||
</div>
|
||
) : (
|
||
Object.keys(methodsByCategory).sort().map(category => (
|
||
<div key={category} style={{ marginBottom: '2rem' }}>
|
||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
||
{category}
|
||
</h2>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||
gap: '1rem'
|
||
}}>
|
||
{methodsByCategory[category].map(method => (
|
||
<div key={method.id} className="card">
|
||
<div style={{ marginBottom: '0.5rem' }}>
|
||
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
|
||
{method.name}
|
||
{method.abbreviation && (
|
||
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
|
||
({method.abbreviation})
|
||
</span>
|
||
)}
|
||
</h3>
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
{method.typical_duration && (
|
||
<span style={{
|
||
fontSize: '0.75rem',
|
||
padding: '0.25rem 0.5rem',
|
||
borderRadius: '4px',
|
||
background: 'var(--surface2)',
|
||
color: 'var(--text2)'
|
||
}}>
|
||
⏱️ {method.typical_duration} min
|
||
</span>
|
||
)}
|
||
{method.typical_group_size && (
|
||
<span style={{
|
||
fontSize: '0.75rem',
|
||
padding: '0.25rem 0.5rem',
|
||
borderRadius: '4px',
|
||
background: 'var(--surface2)',
|
||
color: 'var(--text2)'
|
||
}}>
|
||
👥 {method.typical_group_size}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{method.description && (
|
||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||
{method.description}
|
||
</p>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||
<button
|
||
className="btn btn-secondary"
|
||
style={{ flex: 1 }}
|
||
onClick={() => handleEdit(method, 'method')}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
className="btn"
|
||
style={{
|
||
background: 'var(--danger)',
|
||
color: 'white',
|
||
border: 'none'
|
||
}}
|
||
onClick={() => handleDelete(method, 'method')}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Modal */}
|
||
{showModal && isAdmin && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0,0,0,0.5)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000,
|
||
padding: '1rem'
|
||
}}>
|
||
<div style={{
|
||
background: 'var(--surface)',
|
||
borderRadius: '12px',
|
||
padding: '2rem',
|
||
maxWidth: '600px',
|
||
width: '100%',
|
||
maxHeight: '90vh',
|
||
overflowY: 'auto'
|
||
}}>
|
||
<h2 style={{ marginBottom: '1.5rem' }}>
|
||
{editing
|
||
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
||
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
||
}
|
||
</h2>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-row">
|
||
<label className="form-label">Name *</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.name || ''}
|
||
onChange={(e) => updateFormField('name', e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{modalType === 'method' && (
|
||
<div className="form-row">
|
||
<label className="form-label">Kürzel</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.abbreviation || ''}
|
||
onChange={(e) => updateFormField('abbreviation', e.target.value)}
|
||
maxLength={20}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Kategorie</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.category || ''}
|
||
onChange={(e) => updateFormField('category', e.target.value)}
|
||
placeholder={modalType === 'skill' ? 'z.B. kihon, kumite, kata' : 'z.B. kondition, didaktik'}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Beschreibung</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={formData.description || ''}
|
||
onChange={(e) => updateFormField('description', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{modalType === 'skill' && (
|
||
<div className="form-row">
|
||
<label className="form-label">Wichtigkeit (1-5)</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
min={1}
|
||
max={5}
|
||
value={formData.importance || 3}
|
||
onChange={(e) => updateFormField('importance', parseInt(e.target.value))}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{modalType === 'method' && (
|
||
<>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Typische Dauer (min)</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.typical_duration || ''}
|
||
onChange={(e) => updateFormField('typical_duration', e.target.value ? parseInt(e.target.value) : '')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Gruppengröße</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.typical_group_size || ''}
|
||
onChange={(e) => updateFormField('typical_group_size', e.target.value)}
|
||
placeholder="z.B. 10-20"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Status</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.status || 'active'}
|
||
onChange={(e) => updateFormField('status', e.target.value)}
|
||
>
|
||
<option value="active">Aktiv</option>
|
||
<option value="inactive">Inaktiv</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||
{editing ? 'Speichern' : 'Erstellen'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setShowModal(false)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default SkillsPage
|