diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index 52927f1..f52c0b4 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -576,3 +576,174 @@ def delete_trainer_focus_area(tfa_id: int, session=Depends(require_auth)): conn.commit() return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# TARGET GROUPS (Zielgruppen) +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/target-groups") +def list_target_groups( + status: Optional[str] = Query(default='active'), + training_style_id: Optional[int] = Query(default=None), + session=Depends(require_auth) +): + """List all target groups with hierarchical context.""" + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT tg.*, + ts.name as training_style_name, + fa.name as focus_area_name + FROM target_groups tg + LEFT JOIN training_styles ts ON tg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + """ + params = [] + where = [] + + if status: + where.append("tg.status = %s") + params.append(status) + + if training_style_id: + where.append("tg.training_style_id = %s") + params.append(training_style_id) + + if where: + query += " WHERE " + " AND ".join(where) + + query += " ORDER BY fa.name, ts.name, tg.sort_order, tg.name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/target-groups") +def create_target_group(data: dict, session=Depends(require_auth)): + """Create new target group (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zielgruppen 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 target_groups ( + training_style_id, name, description, + min_age, max_age, sort_order, status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.get('training_style_id'), + name, + data.get('description'), + data.get('min_age'), + data.get('max_age'), + data.get('sort_order', 99), + data.get('status', 'active') + )) + + target_group_id = cur.fetchone()['id'] + conn.commit() + + # Return with hierarchical context + cur.execute(""" + SELECT tg.*, + ts.name as training_style_name, + fa.name as focus_area_name + FROM target_groups tg + LEFT JOIN training_styles ts ON tg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + WHERE tg.id = %s + """, (target_group_id,)) + return r2d(cur.fetchone()) + + +@router.put("/target-groups/{target_group_id}") +def update_target_group(target_group_id: int, data: dict, session=Depends(require_auth)): + """Update target group (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zielgruppen bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE target_groups SET + training_style_id = %s, + name = %s, + description = %s, + min_age = %s, + max_age = %s, + sort_order = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('training_style_id'), + data.get('name'), + data.get('description'), + data.get('min_age'), + data.get('max_age'), + data.get('sort_order'), + data.get('status'), + target_group_id + )) + + conn.commit() + + # Return with hierarchical context + cur.execute(""" + SELECT tg.*, + ts.name as training_style_name, + fa.name as focus_area_name + FROM target_groups tg + LEFT JOIN training_styles ts ON tg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + WHERE tg.id = %s + """, (target_group_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/target-groups/{target_group_id}") +def delete_target_group(target_group_id: int, session=Depends(require_auth)): + """Delete target group (superadmin only). + + Fails if target group is assigned to any exercises (CASCADE protection). + """ + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Zielgruppen löschen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if assigned to exercises + cur.execute(""" + SELECT COUNT(*) as count + FROM exercise_target_groups + WHERE target_group_id = %s + """, (target_group_id,)) + count = cur.fetchone()['count'] + + if count > 0: + raise HTTPException( + 409, + f"Zielgruppe kann nicht gelöscht werden: {count} Übung(en) zugeordnet. " + "Bitte zuerst alle Zuordnungen entfernen oder umrouten." + ) + + cur.execute("DELETE FROM target_groups WHERE id = %s", (target_group_id,)) + conn.commit() + + return {"ok": True} diff --git a/backend/version.py b/backend/version.py index 8198e79..e9caa60 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.3.1" +APP_VERSION = "0.3.2" BUILD_DATE = "2026-04-23" DB_SCHEMA_VERSION = "20260423" @@ -18,10 +18,22 @@ MODULE_VERSIONS = { "import_wiki": "0.1.0", "admin": "1.0.0", "membership": "1.0.0", - "catalogs": "1.1.0", # Updated: Zielgruppen + Hierarchie + "catalogs": "1.2.0", # Updated: Target Groups CRUD + Admin UI } CHANGELOG = [ + { + "version": "0.3.2", + "date": "2026-04-23", + "changes": [ + "Feature: Zielgruppen-Verwaltung komplett (Backend + Frontend)", + "API: GET/POST/PUT/DELETE /target-groups mit hierarchischem Kontext (focus_area → training_style → target_group)", + "Admin UI: Neuer Tab 'Zielgruppen' in Katalogverwaltung", + "UX: Create/Edit-Forms mit Training-Stil-Auswahl, Altersbereich (min/max)", + "UX: Hierarchie-Anzeige in Liste (Fokusbereich → Stil → Zielgruppe)", + "Protection: DELETE mit CASCADE-Schutz (Fehler wenn Übungen zugeordnet)", + ] + }, { "version": "0.3.1", "date": "2026-04-23", diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx index 6bbde05..3194d95 100644 --- a/frontend/src/pages/AdminCatalogsPage.jsx +++ b/frontend/src/pages/AdminCatalogsPage.jsx @@ -26,6 +26,11 @@ export default function AdminCatalogsPage() { const [editingSC, setEditingSC] = useState(null) const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: null }) + // Target Groups + const [targetGroups, setTargetGroups] = useState([]) + const [editingTG, setEditingTG] = useState(null) + const [newTG, setNewTG] = useState({ name: '', description: '', training_style_id: null, min_age: null, max_age: null }) + // Trainer Focus Areas const [trainerAssignments, setTrainerAssignments] = useState([]) const [profiles, setProfiles] = useState([]) @@ -51,6 +56,13 @@ export default function AdminCatalogsPage() { } else if (activeTab === 'skill-categories') { const data = await api.listSkillCategories() setSkillCategories(data) + } else if (activeTab === 'target-groups') { + const [groups, styles] = await Promise.all([ + api.listTargetGroups(), + api.listTrainingStyles() + ]) + setTargetGroups(groups) + setTrainingStyles(styles) } else if (activeTab === 'trainer-assignments') { const [assignments, profs, areas] = await Promise.all([ api.listTrainerFocusAreas(), @@ -192,6 +204,37 @@ export default function AdminCatalogsPage() { } } + // Target Groups + async function createTargetGroup() { + try { + await api.createTargetGroup(newTG) + setNewTG({ name: '', description: '', training_style_id: null, min_age: null, max_age: null }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateTargetGroup(id, data) { + try { + await api.updateTargetGroup(id, data) + setEditingTG(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteTargetGroup(id) { + if (!confirm('Zielgruppe wirklich löschen?')) return + try { + await api.deleteTargetGroup(id) + loadData() + } catch (e) { + setError(e.message) + } + } + // Trainer Assignments async function assignTrainer() { try { @@ -224,6 +267,7 @@ export default function AdminCatalogsPage() { { id: 'training-styles', label: 'Trainingsstile' }, { id: 'training-characters', label: 'Trainingscharakter' }, { id: 'skill-categories', label: 'Fähigkeitskategorien' }, + { id: 'target-groups', label: 'Zielgruppen' }, { id: 'trainer-assignments', label: 'Trainer-Zuordnungen' } ].map(tab => (