From fe5d29e40eaeffeb04265fbbae8f340ceb7158e9 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 15:29:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Training=20Types=20=E2=86=92=20Focus=20?= =?UTF-8?q?Area=20Hierarchie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 013: - Adds focus_area_id to training_types (context-specific types) - Migrates existing data to Karate focus area - Seeds focus-area-specific training types: * Karate: Dan-Vorbereitung * Gewaltschutz: Präventivkurse, Intensivtraining, Spezialkurse * Fitness: Gesundheitssport, Funktionelles Training - Updates unique constraint to (name, focus_area_id) Backend (catalogs.py): - list_training_types: Added focus_area_id filter, LEFT JOIN focus_areas - create_training_type: Added focus_area_id parameter - update_training_type: Added focus_area_id parameter - Enriched responses with focus_area_name and focus_area_icon Frontend (AdminCatalogsPage): - Added Fokusbereich dropdown to create form - Added Fokusbereich dropdown to edit form - Display shows focus_area_icon and focus_area_name - Training types now context-specific to focus areas Co-Authored-By: Claude Sonnet 4.5 --- .../013_training_types_focus_area.sql | 141 ++++++++++++++++++ backend/routers/catalogs.py | 45 +++++- frontend/src/pages/AdminCatalogsPage.jsx | 36 ++++- 3 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/013_training_types_focus_area.sql diff --git a/backend/migrations/013_training_types_focus_area.sql b/backend/migrations/013_training_types_focus_area.sql new file mode 100644 index 0000000..f7b4a69 --- /dev/null +++ b/backend/migrations/013_training_types_focus_area.sql @@ -0,0 +1,141 @@ +-- Migration 013: Training Types → Focus Area Hierarchie +-- Author: Claude Code +-- Date: 2026-04-23 +-- Purpose: Training Types sind kontextabhängig vom Fokusbereich + +DO $$ +BEGIN + +-- ============================================================================ +-- ADD focus_area_id TO training_types +-- ============================================================================ + +-- Add focus_area_id column if not exists +IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'training_types' + AND column_name = 'focus_area_id' +) THEN + ALTER TABLE training_types + ADD COLUMN focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE; + + CREATE INDEX idx_training_types_focus_area ON training_types(focus_area_id); + + RAISE NOTICE 'Added focus_area_id to training_types'; +END IF; + + +-- ============================================================================ +-- MIGRATE EXISTING DATA (assign to Karate if exists) +-- ============================================================================ + +-- Assign existing training types to Karate (if Karate focus area exists) +DO $migrate$ +DECLARE + karate_id INT; +BEGIN + -- Get Karate focus area ID + SELECT id INTO karate_id FROM focus_areas WHERE name = 'Karate' LIMIT 1; + + IF karate_id IS NOT NULL THEN + -- Assign existing training types to Karate + UPDATE training_types + SET focus_area_id = karate_id + WHERE focus_area_id IS NULL; + + RAISE NOTICE 'Assigned existing training types to Karate (focus_area_id: %)', karate_id; + ELSE + RAISE NOTICE 'No Karate focus area found - skipping migration'; + END IF; +END $migrate$; + + +-- ============================================================================ +-- SEED EXAMPLE DATA (focus-area-specific training types) +-- ============================================================================ + +DO $seed$ +DECLARE + karate_id INT; + gewaltschutz_id INT; + fitness_id INT; +BEGIN + -- Get focus area IDs + SELECT id INTO karate_id FROM focus_areas WHERE name = 'Karate' LIMIT 1; + SELECT id INTO gewaltschutz_id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1; + SELECT id INTO fitness_id FROM focus_areas WHERE name = 'Fitness' LIMIT 1; + + -- Karate-specific training types (if Karate exists) + IF karate_id IS NOT NULL THEN + INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status) + VALUES + ('Dan-Vorbereitung', 'DAN', 'Vorbereitung auf Dan-Prüfungen', karate_id, 4, 'active') + ON CONFLICT (name) DO NOTHING; + END IF; + + -- Gewaltschutz-specific training types (if exists) + IF gewaltschutz_id IS NOT NULL THEN + INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status) + VALUES + ('Präventivkurse', 'PREV', 'Präventive Selbstverteidigung und Deeskalation', gewaltschutz_id, 1, 'active'), + ('Intensivtraining', 'INT', 'Intensives Selbstverteidigungs-Training', gewaltschutz_id, 2, 'active'), + ('Spezialkurse', 'SPEC', 'Spezielle Kurse (Frauen, Sicherheitspersonal, etc.)', gewaltschutz_id, 3, 'active') + ON CONFLICT (name) DO NOTHING; + END IF; + + -- Fitness-specific training types (if exists) + IF fitness_id IS NOT NULL THEN + INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status) + VALUES + ('Gesundheitssport', 'GES', 'Gesundheitsorientiertes Training', fitness_id, 1, 'active'), + ('Funktionelles Training', 'FUNKT', 'Funktionelle Fitness und Beweglichkeit', fitness_id, 2, 'active') + ON CONFLICT (name) DO NOTHING; + END IF; + + RAISE NOTICE 'Seeded focus-area-specific training types'; +END $seed$; + + +-- ============================================================================ +-- UPDATE UNIQUE CONSTRAINT (if needed) +-- ============================================================================ + +-- Drop old unique constraint if exists +DO $constraint$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'training_types_name_key' + ) THEN + ALTER TABLE training_types DROP CONSTRAINT training_types_name_key; + RAISE NOTICE 'Dropped old unique constraint on name'; + END IF; +END $constraint$; + +-- Add new unique constraint (name per focus_area) +DO $constraint$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'training_types_name_focus_area_key' + ) THEN + ALTER TABLE training_types + ADD CONSTRAINT training_types_name_focus_area_key + UNIQUE (name, focus_area_id); + + RAISE NOTICE 'Added unique constraint: name per focus_area'; + END IF; +END $constraint$; + + +-- ============================================================================ +-- MIGRATION TRACKING +-- ============================================================================ + +INSERT INTO schema_migrations (version, description) +VALUES (13, 'training_types focus_area hierarchy') +ON CONFLICT (version) DO NOTHING; + +RAISE NOTICE 'Migration 013 completed successfully'; + +END $$; diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index b4073f7..dce1be5 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -372,20 +372,36 @@ def delete_training_character(char_id: int, session=Depends(require_auth)): @router.get("/training-types") def list_training_types( status: Optional[str] = Query(default='active'), + focus_area_id: Optional[int] = Query(default=None), session=Depends(require_auth) ): - """List all training types.""" + """List all training types. + + Optional filter by focus_area_id for context-specific types. + """ with get_db() as conn: cur = get_cursor(conn) - query = "SELECT * FROM training_types" + query = """ + SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon + FROM training_types tt + LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id + """ params = [] + where = [] if status: - query += " WHERE status = %s" + where.append("tt.status = %s") params.append(status) - query += " ORDER BY sort_order, name" + if focus_area_id is not None: + where.append("tt.focus_area_id = %s") + params.append(focus_area_id) + + if where: + query += " WHERE " + " AND ".join(where) + + query += " ORDER BY fa.sort_order, tt.sort_order, tt.name" cur.execute(query, params) rows = cur.fetchall() @@ -407,13 +423,14 @@ def create_training_type(data: dict, session=Depends(require_auth)): cur = get_cursor(conn) cur.execute(""" - INSERT INTO training_types (name, abbreviation, description, sort_order, status) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('abbreviation'), data.get('description'), + data.get('focus_area_id'), data.get('sort_order', 99), data.get('status', 'active') )) @@ -421,7 +438,12 @@ def create_training_type(data: dict, session=Depends(require_auth)): type_id = cur.fetchone()['id'] conn.commit() - cur.execute("SELECT * FROM training_types WHERE id = %s", (type_id,)) + cur.execute(""" + SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon + FROM training_types tt + LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id + WHERE tt.id = %s + """, (type_id,)) return r2d(cur.fetchone()) @@ -440,6 +462,7 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth) name = %s, abbreviation = %s, description = %s, + focus_area_id = %s, sort_order = %s, status = %s, updated_at = NOW() @@ -448,6 +471,7 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth) data.get('name'), data.get('abbreviation'), data.get('description'), + data.get('focus_area_id'), data.get('sort_order'), data.get('status'), type_id @@ -455,7 +479,12 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth) conn.commit() - cur.execute("SELECT * FROM training_types WHERE id = %s", (type_id,)) + cur.execute(""" + SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon + FROM training_types tt + LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id + WHERE tt.id = %s + """, (type_id,)) return r2d(cur.fetchone()) diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx index b90d45c..7ac479d 100644 --- a/frontend/src/pages/AdminCatalogsPage.jsx +++ b/frontend/src/pages/AdminCatalogsPage.jsx @@ -24,7 +24,7 @@ export default function AdminCatalogsPage() { // Training Types (Breitensport, Leistungssport, etc.) const [trainingTypes, setTrainingTypes] = useState([]) const [editingTT, setEditingTT] = useState(null) - const [newTT, setNewTT] = useState({ name: '', abbreviation: '', description: '' }) + const [newTT, setNewTT] = useState({ name: '', abbreviation: '', description: '', focus_area_id: null }) // Skill Categories const [skillCategories, setSkillCategories] = useState([]) @@ -201,7 +201,7 @@ export default function AdminCatalogsPage() { async function createTrainingType() { try { await api.createTrainingType(newTT) - setNewTT({ name: '', abbreviation: '', description: '' }) + setNewTT({ name: '', abbreviation: '', description: '', focus_area_id: null }) loadData() } catch (e) { setError(e.message) @@ -679,6 +679,19 @@ export default function AdminCatalogsPage() { rows={3} /> +
+ + +
@@ -712,6 +725,19 @@ export default function AdminCatalogsPage() { rows={3} /> +
+ + +
@@ -724,6 +750,12 @@ export default function AdminCatalogsPage() { {tt.abbreviation && ({tt.abbreviation})} {tt.description &&

{tt.description}

} + {tt.focus_area_name && ( +

+ Fokusbereich: + {tt.focus_area_icon} {tt.focus_area_name} +

+ )}