From 278d719e8445c2b3c6b3a0ddd0008c22796821ff Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 08:55:54 +0200 Subject: [PATCH] feat: Zielgruppen-Verwaltung (Target Groups CRUD + Admin UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/target-groups: Liste mit hierarchischem Kontext (focus_area → training_style → target_group) - POST /api/target-groups: Create (admin only) - PUT /api/target-groups/{id}: Update (admin only) - DELETE /api/target-groups/{id}: Delete (superadmin only) mit CASCADE-Schutz - Filter: by training_style_id, status Frontend: - api.js: listTargetGroups, createTargetGroup, updateTargetGroup, deleteTargetGroup - AdminCatalogsPage: Neuer Tab "Zielgruppen" (6. Tab) - Create-Form: training_style_id, name, description, min_age, max_age - List-View: Hierarchie-Anzeige (Fokusbereich → Stil → Zielgruppe + Altersbereich) - Inline-Editing mit Stil-Auswahl-Dropdown - Delete mit Confirmation Dialog Architektur: - Hierarchische Beziehung: target_groups.training_style_id → training_styles → focus_areas - CASCADE-Protection: DELETE verweigert wenn exercise_target_groups Einträge existieren - Backend liefert enriched data mit training_style_name + focus_area_name version: 0.3.2 modules: catalogs 1.2.0 pages: AdminCatalogsPage 1.1.0 --- backend/routers/catalogs.py | 171 +++++++++++++++++++ backend/version.py | 16 +- frontend/src/pages/AdminCatalogsPage.jsx | 201 +++++++++++++++++++++++ frontend/src/utils/api.js | 28 ++++ frontend/src/version.js | 4 +- 5 files changed, 416 insertions(+), 4 deletions(-) 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 => (