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")
|
@router.get("/training-types")
|
||||||
def list_training_types(
|
def list_training_types(
|
||||||
status: Optional[str] = Query(default='active'),
|
status: Optional[str] = Query(default='active'),
|
||||||
|
focus_area_id: Optional[int] = Query(default=None),
|
||||||
session=Depends(require_auth)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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 = []
|
params = []
|
||||||
|
where = []
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " WHERE status = %s"
|
where.append("tt.status = %s")
|
||||||
params.append(status)
|
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)
|
cur.execute(query, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
@ -407,13 +423,14 @@ def create_training_type(data: dict, session=Depends(require_auth)):
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO training_types (name, abbreviation, description, sort_order, status)
|
INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status)
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
name,
|
name,
|
||||||
data.get('abbreviation'),
|
data.get('abbreviation'),
|
||||||
data.get('description'),
|
data.get('description'),
|
||||||
|
data.get('focus_area_id'),
|
||||||
data.get('sort_order', 99),
|
data.get('sort_order', 99),
|
||||||
data.get('status', 'active')
|
data.get('status', 'active')
|
||||||
))
|
))
|
||||||
|
|
@ -421,7 +438,12 @@ def create_training_type(data: dict, session=Depends(require_auth)):
|
||||||
type_id = cur.fetchone()['id']
|
type_id = cur.fetchone()['id']
|
||||||
conn.commit()
|
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())
|
return r2d(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -440,6 +462,7 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth)
|
||||||
name = %s,
|
name = %s,
|
||||||
abbreviation = %s,
|
abbreviation = %s,
|
||||||
description = %s,
|
description = %s,
|
||||||
|
focus_area_id = %s,
|
||||||
sort_order = %s,
|
sort_order = %s,
|
||||||
status = %s,
|
status = %s,
|
||||||
updated_at = NOW()
|
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('name'),
|
||||||
data.get('abbreviation'),
|
data.get('abbreviation'),
|
||||||
data.get('description'),
|
data.get('description'),
|
||||||
|
data.get('focus_area_id'),
|
||||||
data.get('sort_order'),
|
data.get('sort_order'),
|
||||||
data.get('status'),
|
data.get('status'),
|
||||||
type_id
|
type_id
|
||||||
|
|
@ -455,7 +479,12 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth)
|
||||||
|
|
||||||
conn.commit()
|
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())
|
return r2d(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default function AdminCatalogsPage() {
|
||||||
// Training Types (Breitensport, Leistungssport, etc.)
|
// Training Types (Breitensport, Leistungssport, etc.)
|
||||||
const [trainingTypes, setTrainingTypes] = useState([])
|
const [trainingTypes, setTrainingTypes] = useState([])
|
||||||
const [editingTT, setEditingTT] = useState(null)
|
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
|
// Skill Categories
|
||||||
const [skillCategories, setSkillCategories] = useState([])
|
const [skillCategories, setSkillCategories] = useState([])
|
||||||
|
|
@ -201,7 +201,7 @@ export default function AdminCatalogsPage() {
|
||||||
async function createTrainingType() {
|
async function createTrainingType() {
|
||||||
try {
|
try {
|
||||||
await api.createTrainingType(newTT)
|
await api.createTrainingType(newTT)
|
||||||
setNewTT({ name: '', abbreviation: '', description: '' })
|
setNewTT({ name: '', abbreviation: '', description: '', focus_area_id: null })
|
||||||
loadData()
|
loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -679,6 +679,19 @@ export default function AdminCatalogsPage() {
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<button className="btn btn-primary" onClick={createTrainingType}>Anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -712,6 +725,19 @@ export default function AdminCatalogsPage() {
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
||||||
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</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>}
|
{tt.abbreviation && <span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text2)' }}>({tt.abbreviation})</span>}
|
||||||
</h3>
|
</h3>
|
||||||
{tt.description && <p style={{ margin: '8px 0', color: 'var(--text2)' }}>{tt.description}</p>}
|
{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' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||||
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
||||||
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user