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)
This commit is contained in:
parent
8e027e02bb
commit
505a8e5e38
|
|
@ -70,16 +70,17 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, clubs
|
||||
from routers import auth, profiles, exercises, clubs, skills
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
app.include_router(exercises.router)
|
||||
app.include_router(clubs.router)
|
||||
app.include_router(skills.router)
|
||||
|
||||
# TODO: Add more routers as they are created
|
||||
# from routers import skills, methods
|
||||
# app.include_router(skills.router, prefix="/api")
|
||||
# from routers import training_planning
|
||||
# app.include_router(training_planning.router, prefix="/api")
|
||||
# ... etc
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
340
backend/routers/skills.py
Normal file
340
backend/routers/skills.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""
|
||||
Skills & Methods Catalog Endpoints for Shinkan Jinkendo
|
||||
|
||||
Handles CRUD operations for skills and training methods.
|
||||
Read access for all authenticated users.
|
||||
Write access for admins only.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
|
||||
|
||||
# ── List Skills ───────────────────────────────────────────────────────
|
||||
@router.get("/skills")
|
||||
def list_skills(
|
||||
category: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
List all skills (public for authenticated users).
|
||||
|
||||
Filters:
|
||||
- category: kihon, kumite, kata, selbstverteidigung, fitness
|
||||
- status: active, inactive
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
query = "SELECT * FROM skills"
|
||||
params = []
|
||||
where = []
|
||||
|
||||
if category:
|
||||
where.append("category = %s")
|
||||
params.append(category)
|
||||
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
else:
|
||||
# Default: only active skills
|
||||
where.append("status = 'active'")
|
||||
|
||||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
|
||||
query += " ORDER BY importance DESC, name"
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
# ── Get Skill ─────────────────────────────────────────────────────────
|
||||
@router.get("/skills/{skill_id}")
|
||||
def get_skill(skill_id: int, session=Depends(require_auth)):
|
||||
"""Get skill by ID."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("SELECT * FROM skills WHERE id = %s", (skill_id,))
|
||||
skill = cur.fetchone()
|
||||
|
||||
if not skill:
|
||||
raise HTTPException(404, "Fähigkeit nicht gefunden")
|
||||
|
||||
return r2d(skill)
|
||||
|
||||
|
||||
# ── Create Skill ──────────────────────────────────────────────────────
|
||||
@router.post("/skills")
|
||||
def create_skill(data: dict, session=Depends(require_auth)):
|
||||
"""Create new skill (admin only)."""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Fähigkeiten erstellen")
|
||||
|
||||
name = data.get('name')
|
||||
if not name:
|
||||
raise HTTPException(400, "Name ist Pflichtfeld")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO skills (name, category, description, importance, keywords, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
name,
|
||||
data.get('category'),
|
||||
data.get('description'),
|
||||
data.get('importance'),
|
||||
data.get('keywords'),
|
||||
data.get('status', 'active')
|
||||
))
|
||||
|
||||
skill_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
|
||||
return get_skill(skill_id, session)
|
||||
|
||||
|
||||
# ── Update Skill ──────────────────────────────────────────────────────
|
||||
@router.put("/skills/{skill_id}")
|
||||
def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
|
||||
"""Update skill (admin only)."""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Fähigkeiten bearbeiten")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence
|
||||
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Fähigkeit nicht gefunden")
|
||||
|
||||
# Update
|
||||
cur.execute("""
|
||||
UPDATE skills SET
|
||||
name = %s,
|
||||
category = %s,
|
||||
description = %s,
|
||||
importance = %s,
|
||||
keywords = %s,
|
||||
status = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('name'),
|
||||
data.get('category'),
|
||||
data.get('description'),
|
||||
data.get('importance'),
|
||||
data.get('keywords'),
|
||||
data.get('status'),
|
||||
skill_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_skill(skill_id, session)
|
||||
|
||||
|
||||
# ── Delete Skill ──────────────────────────────────────────────────────
|
||||
@router.delete("/skills/{skill_id}")
|
||||
def delete_skill(skill_id: int, session=Depends(require_auth)):
|
||||
"""Delete skill (superadmin only)."""
|
||||
role = session.get('role')
|
||||
if role != 'superadmin':
|
||||
raise HTTPException(403, "Nur Superadmins dürfen Fähigkeiten löschen")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence
|
||||
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Fähigkeit nicht gefunden")
|
||||
|
||||
# Delete
|
||||
cur.execute("DELETE FROM skills WHERE id = %s", (skill_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── List Training Methods ─────────────────────────────────────────────
|
||||
@router.get("/methods")
|
||||
def list_methods(
|
||||
category: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
List all training methods (public for authenticated users).
|
||||
|
||||
Filters:
|
||||
- category: kondition, didaktik, koordination, kraft, etc.
|
||||
- status: active, inactive
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
query = "SELECT * FROM training_methods"
|
||||
params = []
|
||||
where = []
|
||||
|
||||
if category:
|
||||
where.append("category = %s")
|
||||
params.append(category)
|
||||
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
else:
|
||||
# Default: only active methods
|
||||
where.append("status = 'active'")
|
||||
|
||||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
|
||||
query += " ORDER BY name"
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
# ── Get Training Method ───────────────────────────────────────────────
|
||||
@router.get("/methods/{method_id}")
|
||||
def get_method(method_id: int, session=Depends(require_auth)):
|
||||
"""Get training method by ID."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("SELECT * FROM training_methods WHERE id = %s", (method_id,))
|
||||
method = cur.fetchone()
|
||||
|
||||
if not method:
|
||||
raise HTTPException(404, "Trainingsmethode nicht gefunden")
|
||||
|
||||
return r2d(method)
|
||||
|
||||
|
||||
# ── Create Training Method ────────────────────────────────────────────
|
||||
@router.post("/methods")
|
||||
def create_method(data: dict, session=Depends(require_auth)):
|
||||
"""Create new training method (admin only)."""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Trainingsmethoden erstellen")
|
||||
|
||||
name = data.get('name')
|
||||
if not name:
|
||||
raise HTTPException(400, "Name ist Pflichtfeld")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO training_methods (
|
||||
name, abbreviation, category, description,
|
||||
typical_duration, typical_group_size,
|
||||
related_skills, keywords, status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
name,
|
||||
data.get('abbreviation'),
|
||||
data.get('category'),
|
||||
data.get('description'),
|
||||
data.get('typical_duration'),
|
||||
data.get('typical_group_size'),
|
||||
data.get('related_skills'),
|
||||
data.get('keywords'),
|
||||
data.get('status', 'active')
|
||||
))
|
||||
|
||||
method_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
|
||||
return get_method(method_id, session)
|
||||
|
||||
|
||||
# ── Update Training Method ────────────────────────────────────────────
|
||||
@router.put("/methods/{method_id}")
|
||||
def update_method(method_id: int, data: dict, session=Depends(require_auth)):
|
||||
"""Update training method (admin only)."""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Trainingsmethoden bearbeiten")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence
|
||||
cur.execute("SELECT id FROM training_methods WHERE id = %s", (method_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Trainingsmethode nicht gefunden")
|
||||
|
||||
# Update
|
||||
cur.execute("""
|
||||
UPDATE training_methods SET
|
||||
name = %s,
|
||||
abbreviation = %s,
|
||||
category = %s,
|
||||
description = %s,
|
||||
typical_duration = %s,
|
||||
typical_group_size = %s,
|
||||
related_skills = %s,
|
||||
keywords = %s,
|
||||
status = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('name'),
|
||||
data.get('abbreviation'),
|
||||
data.get('category'),
|
||||
data.get('description'),
|
||||
data.get('typical_duration'),
|
||||
data.get('typical_group_size'),
|
||||
data.get('related_skills'),
|
||||
data.get('keywords'),
|
||||
data.get('status'),
|
||||
method_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_method(method_id, session)
|
||||
|
||||
|
||||
# ── Delete Training Method ────────────────────────────────────────────
|
||||
@router.delete("/methods/{method_id}")
|
||||
def delete_method(method_id: int, session=Depends(require_auth)):
|
||||
"""Delete training method (superadmin only)."""
|
||||
role = session.get('role')
|
||||
if role != 'superadmin':
|
||||
raise HTTPException(403, "Nur Superadmins dürfen Trainingsmethoden löschen")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence
|
||||
cur.execute("SELECT id FROM training_methods WHERE id = %s", (method_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Trainingsmethode nicht gefunden")
|
||||
|
||||
# Delete
|
||||
cur.execute("DELETE FROM training_methods WHERE id = %s", (method_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
|
@ -8,6 +8,7 @@ import Dashboard from './pages/Dashboard'
|
|||
import ProfilePage from './pages/ProfilePage'
|
||||
import ExercisesPage from './pages/ExercisesPage'
|
||||
import ClubsPage from './pages/ClubsPage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
import './app.css'
|
||||
|
||||
// Bottom Navigation (Mobile)
|
||||
|
|
@ -158,6 +159,14 @@ function AppRoutes() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/skills"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SkillsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all - redirect to dashboard or login */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
517
frontend/src/pages/SkillsPage.jsx
Normal file
517
frontend/src/pages/SkillsPage.jsx
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
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
|
||||
|
|
@ -144,8 +144,13 @@ export async function deleteTrainingGroup(id) {
|
|||
// Skills & Methods
|
||||
// ============================================================================
|
||||
|
||||
export async function listSkills() {
|
||||
return request('/api/skills')
|
||||
export async function listSkills(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
return request(`/api/skills${query ? '?' + query : ''}`)
|
||||
}
|
||||
|
||||
export async function getSkill(id) {
|
||||
return request(`/api/skills/${id}`)
|
||||
}
|
||||
|
||||
export async function createSkill(data) {
|
||||
|
|
@ -155,8 +160,24 @@ export async function createSkill(data) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function listMethods() {
|
||||
return request('/api/methods')
|
||||
export async function updateSkill(id, data) {
|
||||
return request(`/api/skills/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSkill(id) {
|
||||
return request(`/api/skills/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listMethods(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
return request(`/api/methods${query ? '?' + query : ''}`)
|
||||
}
|
||||
|
||||
export async function getMethod(id) {
|
||||
return request(`/api/methods/${id}`)
|
||||
}
|
||||
|
||||
export async function createMethod(data) {
|
||||
|
|
@ -166,6 +187,17 @@ export async function createMethod(data) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function updateMethod(id, data) {
|
||||
return request(`/api/methods/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteMethod(id) {
|
||||
return request(`/api/methods/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exercises
|
||||
// ============================================================================
|
||||
|
|
@ -261,9 +293,15 @@ export const api = {
|
|||
|
||||
// Skills & Methods
|
||||
listSkills,
|
||||
getSkill,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
listMethods,
|
||||
getMethod,
|
||||
createMethod,
|
||||
updateMethod,
|
||||
deleteMethod,
|
||||
|
||||
// Exercises
|
||||
listExercises,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user