From c7cda032019f4b71f2e9e7d6216a55e4a3be9d77 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 07:52:03 +0200 Subject: [PATCH] feat: Admin-managed exercise catalogs + frontend integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (already committed): - Migration 007: focus_areas, training_styles, training_characters, skill_categories tables - routers/catalogs.py: 20 CRUD endpoints for all catalogs - routers/exercises.py: Updated to support new FK fields - Trainer focus area assignment for role-based filtering Frontend (new): - AdminCatalogsPage: Comprehensive admin UI with 5 tabs - Focus Areas (with color + icon) - Training Styles (hierarchical with parent_style_id) - Training Characters - Skill Categories (hierarchical) - Trainer Assignments (trainer → focus area mapping) - ExercisesPage: Updated to use catalog dropdowns - Focus area dropdown now loads from API - Added missing Training Style dropdown - Training character dropdown now loads from API - Uses IDs instead of hard-coded text values - App.jsx: Added /admin/catalogs route - api.js: Added all catalog endpoints All form fields standardized: labels on top, full width, left-aligned Ready for testing via /admin/catalogs --- frontend/src/App.jsx | 9 + frontend/src/pages/AdminCatalogsPage.jsx | 698 +++++++++++++++++++++++ frontend/src/pages/ExercisesPage.jsx | 73 ++- frontend/src/utils/api.js | 138 +++++ 4 files changed, 901 insertions(+), 17 deletions(-) create mode 100644 frontend/src/pages/AdminCatalogsPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 06bbdef..b7ef534 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import ExercisesPage from './pages/ExercisesPage' import ClubsPage from './pages/ClubsPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' +import AdminCatalogsPage from './pages/AdminCatalogsPage' import './app.css' // Bottom Navigation (Mobile) @@ -176,6 +177,14 @@ function AppRoutes() { } /> + + + + } + /> {/* Catch all - redirect to dashboard or login */} } /> diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx new file mode 100644 index 0000000..6bbde05 --- /dev/null +++ b/frontend/src/pages/AdminCatalogsPage.jsx @@ -0,0 +1,698 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' + +export default function AdminCatalogsPage() { + const [activeTab, setActiveTab] = useState('focus-areas') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + // Focus Areas + const [focusAreas, setFocusAreas] = useState([]) + const [editingFA, setEditingFA] = useState(null) + const [newFA, setNewFA] = useState({ name: '', description: '', color: '#1D9E75', icon: '' }) + + // Training Styles + const [trainingStyles, setTrainingStyles] = useState([]) + const [editingTS, setEditingTS] = useState(null) + const [newTS, setNewTS] = useState({ name: '', description: '', parent_style_id: null }) + + // Training Characters + const [trainingCharacters, setTrainingCharacters] = useState([]) + const [editingTC, setEditingTC] = useState(null) + const [newTC, setNewTC] = useState({ name: '', description: '' }) + + // Skill Categories + const [skillCategories, setSkillCategories] = useState([]) + const [editingSC, setEditingSC] = useState(null) + const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: null }) + + // Trainer Focus Areas + const [trainerAssignments, setTrainerAssignments] = useState([]) + const [profiles, setProfiles] = useState([]) + const [newAssignment, setNewAssignment] = useState({ profile_id: '', focus_area_id: '' }) + + useEffect(() => { + loadData() + }, [activeTab]) + + async function loadData() { + setLoading(true) + setError('') + try { + if (activeTab === 'focus-areas') { + const data = await api.listFocusAreas() + setFocusAreas(data) + } else if (activeTab === 'training-styles') { + const data = await api.listTrainingStyles() + setTrainingStyles(data) + } else if (activeTab === 'training-characters') { + const data = await api.listTrainingCharacters() + setTrainingCharacters(data) + } else if (activeTab === 'skill-categories') { + const data = await api.listSkillCategories() + setSkillCategories(data) + } else if (activeTab === 'trainer-assignments') { + const [assignments, profs, areas] = await Promise.all([ + api.listTrainerFocusAreas(), + fetch('/api/profiles').then(r => r.json()), + api.listFocusAreas() + ]) + setTrainerAssignments(assignments) + setProfiles(profs) + setFocusAreas(areas) + } + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + // Focus Areas + async function createFocusArea() { + try { + await api.createFocusArea(newFA) + setNewFA({ name: '', description: '', color: '#1D9E75', icon: '' }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateFocusArea(id, data) { + try { + await api.updateFocusArea(id, data) + setEditingFA(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteFocusArea(id) { + if (!confirm('Fokusbereich wirklich löschen?')) return + try { + await api.deleteFocusArea(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + // Training Styles + async function createTrainingStyle() { + try { + await api.createTrainingStyle(newTS) + setNewTS({ name: '', description: '', parent_style_id: null }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateTrainingStyle(id, data) { + try { + await api.updateTrainingStyle(id, data) + setEditingTS(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteTrainingStyle(id) { + if (!confirm('Trainingsstil wirklich löschen?')) return + try { + await api.deleteTrainingStyle(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + // Training Characters + async function createTrainingCharacter() { + try { + await api.createTrainingCharacter(newTC) + setNewTC({ name: '', description: '' }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateTrainingCharacter(id, data) { + try { + await api.updateTrainingCharacter(id, data) + setEditingTC(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteTrainingCharacter(id) { + if (!confirm('Trainingscharakter wirklich löschen?')) return + try { + await api.deleteTrainingCharacter(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + // Skill Categories + async function createSkillCategory() { + try { + await api.createSkillCategory(newSC) + setNewSC({ name: '', description: '', parent_category_id: null }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateSkillCategory(id, data) { + try { + await api.updateSkillCategory(id, data) + setEditingSC(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteSkillCategory(id) { + if (!confirm('Fähigkeitskategorie wirklich löschen?')) return + try { + await api.deleteSkillCategory(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + // Trainer Assignments + async function assignTrainer() { + try { + await api.assignTrainerFocusArea(newAssignment) + setNewAssignment({ profile_id: '', focus_area_id: '' }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function removeAssignment(id) { + if (!confirm('Zuordnung wirklich entfernen?')) return + try { + await api.deleteTrainerFocusArea(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + return ( +
+

Stammdaten-Kataloge

+ + {/* Tabs */} +
+ {[ + { id: 'focus-areas', label: 'Fokusbereiche' }, + { id: 'training-styles', label: 'Trainingsstile' }, + { id: 'training-characters', label: 'Trainingscharakter' }, + { id: 'skill-categories', label: 'Fähigkeitskategorien' }, + { id: 'trainer-assignments', label: 'Trainer-Zuordnungen' } + ].map(tab => ( + + ))} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ ) : ( + <> + {/* Focus Areas */} + {activeTab === 'focus-areas' && ( +
+
+

Neuer Fokusbereich

+
+ + setNewFA({ ...newFA, name: e.target.value })} + placeholder="z.B. Karate" + /> +
+
+ +