All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
Test Suite / pytest-backend (pull_request) Successful in 35s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s
- Introduced `_normalize_mw_category` function to clean category names for API calls, ensuring consistent handling of category prefixes. - Updated `SmwClient` methods to utilize normalized category names, improving data retrieval accuracy. - Added `_wiki_category_or_default` function to provide default categories based on import type, enhancing user experience during imports. - Integrated new fields `karate_relevance` and `relevance_level` into various admin components, allowing for better skill management. - Incremented app version to 0.8.145 and updated changelog to reflect these changes.
519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
||
import api from '../utils/api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import PageSectionNav from '../components/PageSectionNav'
|
||
|
||
const SKILLS_SECTION_TABS = [
|
||
{ id: 'skills', label: 'Fähigkeiten' },
|
||
{ id: 'methods', label: 'Trainingsmethoden' },
|
||
]
|
||
|
||
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',
|
||
karate_relevance: '',
|
||
relevance_level: ''
|
||
})
|
||
} 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') {
|
||
const raw = { ...formData }
|
||
raw.karate_relevance =
|
||
typeof raw.karate_relevance === 'string' && raw.karate_relevance.trim()
|
||
? raw.karate_relevance.trim()
|
||
: null
|
||
raw.relevance_level =
|
||
raw.relevance_level === '' || raw.relevance_level == null || raw.relevance_level === undefined
|
||
? null
|
||
: Number(raw.relevance_level)
|
||
|
||
if (editing) {
|
||
await api.updateSkill(editing.id, raw)
|
||
} else {
|
||
await api.createSkill(raw)
|
||
}
|
||
} 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 className="skills-page__loading">
|
||
<div className="spinner"></div>
|
||
<p>Laden...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const skillsByCategory = groupByCategory(skills)
|
||
const methodsByCategory = groupByCategory(methods)
|
||
|
||
return (
|
||
<div className="app-page skills-page">
|
||
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
||
|
||
<PageSectionNav
|
||
ariaLabel="Bereich wählen"
|
||
value={activeTab}
|
||
onChange={setActiveTab}
|
||
items={SKILLS_SECTION_TABS}
|
||
className="skills-page__tabs-scroll"
|
||
/>
|
||
|
||
{/* Skills Tab */}
|
||
{activeTab === 'skills' && (
|
||
<>
|
||
<div className="skills-page__intro-row">
|
||
<p>
|
||
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 className="skills-page__empty">
|
||
Keine Fähigkeiten gefunden
|
||
</p>
|
||
</div>
|
||
) : (
|
||
Object.keys(skillsByCategory).sort().map(category => (
|
||
<div key={category} className="skills-page__category">
|
||
<h2 className="skills-page__category-title">
|
||
{category}
|
||
</h2>
|
||
<div className="skills-page__card-grid">
|
||
{skillsByCategory[category].map(skill => (
|
||
<div key={skill.id} className="card skills-page-card">
|
||
<div className="skills-page-card__head">
|
||
<h3 className="skills-page-card__title">{skill.name}</h3>
|
||
{skill.importance && (
|
||
<span className="skills-page-card__badge">
|
||
⭐ {skill.importance}/5
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{skill.description && (
|
||
<p className="skills-page-card__desc">
|
||
{skill.description}
|
||
</p>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div className="skills-page-card__actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary skills-page-card__grow"
|
||
onClick={() => handleEdit(skill, 'skill')}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-danger"
|
||
onClick={() => handleDelete(skill, 'skill')}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Methods Tab */}
|
||
{activeTab === 'methods' && (
|
||
<>
|
||
<div className="skills-page__intro-row">
|
||
<p>
|
||
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 className="skills-page__empty">
|
||
Keine Trainingsmethoden gefunden
|
||
</p>
|
||
</div>
|
||
) : (
|
||
Object.keys(methodsByCategory).sort().map(category => (
|
||
<div key={category} className="skills-page__category">
|
||
<h2 className="skills-page__category-title">
|
||
{category}
|
||
</h2>
|
||
<div className="skills-page__card-grid skills-page__card-grid--methods">
|
||
{methodsByCategory[category].map(method => (
|
||
<div key={method.id} className="card skills-page-card">
|
||
<div className="skills-page-card__meta-block">
|
||
<h3 className="skills-page-card__title skills-page-card__title--method">
|
||
{method.name}
|
||
{method.abbreviation && (
|
||
<span className="skills-page-card__abbr">
|
||
({method.abbreviation})
|
||
</span>
|
||
)}
|
||
</h3>
|
||
<div className="skills-page-card__meta-row">
|
||
{method.typical_duration && (
|
||
<span className="skills-page-card__chip">
|
||
⏱️ {method.typical_duration} min
|
||
</span>
|
||
)}
|
||
{method.typical_group_size && (
|
||
<span className="skills-page-card__chip">
|
||
👥 {method.typical_group_size}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{method.description && (
|
||
<p className="skills-page-card__desc">
|
||
{method.description}
|
||
</p>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<div className="skills-page-card__actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary skills-page-card__grow"
|
||
onClick={() => handleEdit(method, 'method')}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-danger"
|
||
onClick={() => handleDelete(method, 'method')}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Modal */}
|
||
{showModal && isAdmin && (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setShowModal(false)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet skills-page-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="skills-page-modal-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h2 id="skills-page-modal-title" className="admin-modal-sheet__title">
|
||
{editing
|
||
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
||
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
||
}
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setShowModal(false)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body">
|
||
<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">Karate-Relevanz (Wiki)</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={formData.karate_relevance || ''}
|
||
onChange={(e) => updateFormField('karate_relevance', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Relevanzgrad (Wiki, 1–3)</label>
|
||
<select
|
||
className="form-input"
|
||
value={
|
||
formData.relevance_level === null ||
|
||
formData.relevance_level === undefined ||
|
||
formData.relevance_level === ''
|
||
? ''
|
||
: String(formData.relevance_level)
|
||
}
|
||
onChange={(e) =>
|
||
updateFormField('relevance_level', e.target.value === '' ? '' : e.target.value)
|
||
}
|
||
>
|
||
<option value="">– nicht gesetzt –</option>
|
||
<option value="1">1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
</select>
|
||
</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 className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||
<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 className="skills-page-modal__footer">
|
||
<button type="submit" className="btn btn-primary skills-page-modal__submit">
|
||
{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
|