shinkan-jinkendo/frontend/src/pages/SkillsPage.jsx
Lars 505a8e5e38
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 14s
feat: Skills & Methods catalog complete
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)
2026-04-22 16:50:31 +02:00

518 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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