feat: Training Types → Focus Area Hierarchie
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 <noreply@anthropic.com>
This commit is contained in:
parent
9f44cff77b
commit
fe5d29e40e
141
backend/migrations/013_training_types_focus_area.sql
Normal file
141
backend/migrations/013_training_types_focus_area.sql
Normal file
|
|
@ -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 $$;
|
||||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokusbereich</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newTT.focus_area_id || ''}
|
||||
onChange={e => setNewTT({ ...newTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
|
||||
>
|
||||
<option value="">- Fokusbereich auswählen -</option>
|
||||
{focusAreas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={createTrainingType}>Anlegen</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -712,6 +725,19 @@ export default function AdminCatalogsPage() {
|
|||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokusbereich</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editingTT.focus_area_id || ''}
|
||||
onChange={e => setEditingTT({ ...editingTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
|
||||
>
|
||||
<option value="">- Fokusbereich auswählen -</option>
|
||||
{focusAreas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
|
||||
|
|
@ -724,6 +750,12 @@ export default function AdminCatalogsPage() {
|
|||
{tt.abbreviation && <span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text2)' }}>({tt.abbreviation})</span>}
|
||||
</h3>
|
||||
{tt.description && <p style={{ margin: '8px 0', color: 'var(--text2)' }}>{tt.description}</p>}
|
||||
{tt.focus_area_name && (
|
||||
<p style={{ margin: '8px 0', fontSize: '14px' }}>
|
||||
<span style={{ color: 'var(--text2)' }}>Fokusbereich: </span>
|
||||
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
||||
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user