From 505a8e5e38eb6a3af3ef8f06ab56ebe741e10f4a Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 16:50:31 +0200 Subject: [PATCH] feat: Skills & Methods catalog complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/main.py | 7 +- backend/routers/skills.py | 340 ++++++++++++++++++++ frontend/src/App.jsx | 9 + frontend/src/pages/SkillsPage.jsx | 517 ++++++++++++++++++++++++++++++ frontend/src/utils/api.js | 46 ++- 5 files changed, 912 insertions(+), 7 deletions(-) create mode 100644 backend/routers/skills.py create mode 100644 frontend/src/pages/SkillsPage.jsx diff --git a/backend/main.py b/backend/main.py index 42427a4..4924f04 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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__": diff --git a/backend/routers/skills.py b/backend/routers/skills.py new file mode 100644 index 0000000..b5b09d1 --- /dev/null +++ b/backend/routers/skills.py @@ -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} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e3fb0c3..a0e523d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> + + + + } + /> {/* Catch all - redirect to dashboard or login */} } /> diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx new file mode 100644 index 0000000..ddafad8 --- /dev/null +++ b/frontend/src/pages/SkillsPage.jsx @@ -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 ( +
+
+

Laden...

+
+ ) + } + + const skillsByCategory = groupByCategory(skills) + const methodsByCategory = groupByCategory(methods) + + return ( +
+
+

Fähigkeiten & Methoden

+ + {/* Tabs */} +
+ {['skills', 'methods'].map(tab => ( + + ))} +
+ + {/* Skills Tab */} + {activeTab === 'skills' && ( + <> +
+

+ Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden. +

+ {isAdmin && ( + + )} +
+ + {Object.keys(skillsByCategory).length === 0 ? ( +
+

+ Keine Fähigkeiten gefunden +

+
+ ) : ( + Object.keys(skillsByCategory).sort().map(category => ( +
+

+ {category} +

+
+ {skillsByCategory[category].map(skill => ( +
+
+

{skill.name}

+ {skill.importance && ( + + ⭐ {skill.importance}/5 + + )} +
+ + {skill.description && ( +

+ {skill.description} +

+ )} + + {isAdmin && ( +
+ + +
+ )} +
+ ))} +
+
+ )) + )} + + )} + + {/* Methods Tab */} + {activeTab === 'methods' && ( + <> +
+

+ Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung. +

+ {isAdmin && ( + + )} +
+ + {Object.keys(methodsByCategory).length === 0 ? ( +
+

+ Keine Trainingsmethoden gefunden +

+
+ ) : ( + Object.keys(methodsByCategory).sort().map(category => ( +
+

+ {category} +

+
+ {methodsByCategory[category].map(method => ( +
+
+

+ {method.name} + {method.abbreviation && ( + + ({method.abbreviation}) + + )} +

+
+ {method.typical_duration && ( + + ⏱️ {method.typical_duration} min + + )} + {method.typical_group_size && ( + + 👥 {method.typical_group_size} + + )} +
+
+ + {method.description && ( +

+ {method.description} +

+ )} + + {isAdmin && ( +
+ + +
+ )} +
+ ))} +
+
+ )) + )} + + )} + + {/* Modal */} + {showModal && isAdmin && ( +
+
+

+ {editing + ? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten') + : (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode') + } +

+ +
+
+ + updateFormField('name', e.target.value)} + required + /> +
+ + {modalType === 'method' && ( +
+ + updateFormField('abbreviation', e.target.value)} + maxLength={20} + /> +
+ )} + +
+ + updateFormField('category', e.target.value)} + placeholder={modalType === 'skill' ? 'z.B. kihon, kumite, kata' : 'z.B. kondition, didaktik'} + /> +
+ +
+ +