feat: Trainer-Kontext-System & Exercise Training Characters (v0.5.0)
Migration 012:
- exercise_training_characters (M:N junction table)
- trainer_contexts (Fokussierte Trainer-Ansichten)
- Indizes für Performance
- Example seed data
Backend (catalogs.py):
- GET /api/trainer-contexts (list own contexts)
- POST /api/trainer-contexts (create context)
- PUT /api/trainer-contexts/{id} (update own context)
- DELETE /api/trainer-contexts/{id} (delete own context)
- Enriched responses mit focus_area_name, style_direction_name, training_type_name
- Ownership-Validation (nur eigene Kontexte)
Frontend:
- TrainerContextsPage.jsx (vollständige CRUD-UI)
- Kaskadierende Dropdowns (Fokusbereich → Stilrichtung)
- is_style_independent Flag für stilunabhängige Kontexte
- api.js erweitert (listTrainerContexts, create, update, delete)
Architektur:
- Flat Catalogs mit M:N überall
- NULL = 'für alles geeignet'
- Trainer-Kontexte für fokussierte Ansichten
- Vorbereitung für 1000+ Übungen mit flexibler KI-Filterung
version: 0.5.0 (backend + frontend)
module: exercises 0.5.0, catalogs 1.5.0
page: TrainerContextsPage 1.0.0
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9ab2bd31fa
commit
5e2820c63c
|
|
@ -0,0 +1,120 @@
|
||||||
|
-- Migration 012: Exercise Training Characters (M:N) + Trainer Contexts
|
||||||
|
-- Author: Claude Code
|
||||||
|
-- Date: 2026-04-23
|
||||||
|
-- Purpose: Add M:N relationship for training characters and trainer profile system
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- EXERCISE TRAINING CHARACTERS (M:N)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create junction table for exercise ↔ training_characters
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'exercise_training_characters'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE exercise_training_characters (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
training_character_id INT NOT NULL REFERENCES training_characters(id) ON DELETE RESTRICT,
|
||||||
|
is_primary BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(exercise_id, training_character_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_exercise_training_characters_exercise
|
||||||
|
ON exercise_training_characters(exercise_id);
|
||||||
|
CREATE INDEX idx_exercise_training_characters_character
|
||||||
|
ON exercise_training_characters(training_character_id);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created table: exercise_training_characters';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRAINER CONTEXTS (Fokussierte Trainer-Ansichten)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Create trainer_contexts table
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'trainer_contexts'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE trainer_contexts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Context definition
|
||||||
|
name VARCHAR(100) NOT NULL, -- "Karate Goju-Ryu Breitensport", "Gewaltschutz"
|
||||||
|
|
||||||
|
-- Hierarchical filters (all optional)
|
||||||
|
focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||||
|
style_direction_id INT REFERENCES style_directions(id) ON DELETE CASCADE,
|
||||||
|
training_type_id INT REFERENCES training_types(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_style_independent BOOLEAN DEFAULT false, -- true = "Leistungssport stilunabhängig"
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
description TEXT,
|
||||||
|
sort_order INT DEFAULT 99,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Prevent duplicates
|
||||||
|
UNIQUE(profile_id, focus_area_id, style_direction_id, training_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_trainer_contexts_profile ON trainer_contexts(profile_id);
|
||||||
|
CREATE INDEX idx_trainer_contexts_focus ON trainer_contexts(focus_area_id);
|
||||||
|
CREATE INDEX idx_trainer_contexts_style ON trainer_contexts(style_direction_id);
|
||||||
|
CREATE INDEX idx_trainer_contexts_type ON trainer_contexts(training_type_id);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created table: trainer_contexts';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SEED DATA: Example Trainer Contexts
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Insert example contexts for profile_id = 1 (if exists)
|
||||||
|
DO $seed$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM profiles WHERE id = 1) THEN
|
||||||
|
-- Only insert if not already present
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM trainer_contexts WHERE profile_id = 1) THEN
|
||||||
|
INSERT INTO trainer_contexts (profile_id, name, focus_area_id, style_direction_id, training_type_id, is_style_independent, description, sort_order)
|
||||||
|
VALUES
|
||||||
|
-- Karate Breitensport (if focus_area and style exist)
|
||||||
|
(1, 'Karate Breitensport',
|
||||||
|
(SELECT id FROM focus_areas WHERE name = 'Karate' LIMIT 1),
|
||||||
|
NULL, -- all styles
|
||||||
|
(SELECT id FROM training_types WHERE name = 'Breitensport' LIMIT 1),
|
||||||
|
false,
|
||||||
|
'Breitensport für alle Karate-Stilrichtungen',
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Inserted example trainer contexts for profile_id = 1';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $seed$;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- MIGRATION TRACKING
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO schema_migrations (version, description)
|
||||||
|
VALUES (12, 'exercise_training_characters + trainer_contexts')
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Migration 012 completed successfully';
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
@ -1097,3 +1097,169 @@ def get_training_styles_hierarchy(
|
||||||
fa['style_directions'] = style_directions
|
fa['style_directions'] = style_directions
|
||||||
|
|
||||||
return focus_areas
|
return focus_areas
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
# TRAINER CONTEXTS (Fokussierte Trainer-Ansichten)
|
||||||
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@router.get("/trainer-contexts")
|
||||||
|
def list_trainer_contexts(session=Depends(require_auth)):
|
||||||
|
"""List all trainer contexts for the current user.
|
||||||
|
|
||||||
|
Returns enriched data with focus_area_name, style_direction_name, training_type_name.
|
||||||
|
"""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
tc.*,
|
||||||
|
fa.name as focus_area_name,
|
||||||
|
fa.icon as focus_area_icon,
|
||||||
|
sd.name as style_direction_name,
|
||||||
|
tt.name as training_type_name
|
||||||
|
FROM trainer_contexts tc
|
||||||
|
LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id
|
||||||
|
LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id
|
||||||
|
LEFT JOIN training_types tt ON tc.training_type_id = tt.id
|
||||||
|
WHERE tc.profile_id = %s
|
||||||
|
ORDER BY tc.sort_order, tc.name
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [r2d(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trainer-contexts")
|
||||||
|
def create_trainer_context(data: dict, session=Depends(require_auth)):
|
||||||
|
"""Create new trainer context for the current user."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
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 trainer_contexts (
|
||||||
|
profile_id, name, focus_area_id, style_direction_id,
|
||||||
|
training_type_id, is_style_independent, description, sort_order, is_active
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (
|
||||||
|
profile_id,
|
||||||
|
name,
|
||||||
|
data.get('focus_area_id'),
|
||||||
|
data.get('style_direction_id'),
|
||||||
|
data.get('training_type_id'),
|
||||||
|
data.get('is_style_independent', False),
|
||||||
|
data.get('description'),
|
||||||
|
data.get('sort_order', 99),
|
||||||
|
data.get('is_active', True)
|
||||||
|
))
|
||||||
|
|
||||||
|
context_id = cur.fetchone()['id']
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Return enriched record
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
tc.*,
|
||||||
|
fa.name as focus_area_name,
|
||||||
|
sd.name as style_direction_name,
|
||||||
|
tt.name as training_type_name
|
||||||
|
FROM trainer_contexts tc
|
||||||
|
LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id
|
||||||
|
LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id
|
||||||
|
LEFT JOIN training_types tt ON tc.training_type_id = tt.id
|
||||||
|
WHERE tc.id = %s
|
||||||
|
""", (context_id,))
|
||||||
|
|
||||||
|
return r2d(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/trainer-contexts/{context_id}")
|
||||||
|
def update_trainer_context(context_id: int, data: dict, session=Depends(require_auth)):
|
||||||
|
"""Update trainer context (own contexts only)."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Kontext nicht gefunden")
|
||||||
|
if row['profile_id'] != profile_id:
|
||||||
|
raise HTTPException(403, "Zugriff verweigert")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE trainer_contexts SET
|
||||||
|
name = %s,
|
||||||
|
focus_area_id = %s,
|
||||||
|
style_direction_id = %s,
|
||||||
|
training_type_id = %s,
|
||||||
|
is_style_independent = %s,
|
||||||
|
description = %s,
|
||||||
|
sort_order = %s,
|
||||||
|
is_active = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (
|
||||||
|
data.get('name'),
|
||||||
|
data.get('focus_area_id'),
|
||||||
|
data.get('style_direction_id'),
|
||||||
|
data.get('training_type_id'),
|
||||||
|
data.get('is_style_independent', False),
|
||||||
|
data.get('description'),
|
||||||
|
data.get('sort_order'),
|
||||||
|
data.get('is_active', True),
|
||||||
|
context_id
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Return enriched record
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
tc.*,
|
||||||
|
fa.name as focus_area_name,
|
||||||
|
sd.name as style_direction_name,
|
||||||
|
tt.name as training_type_name
|
||||||
|
FROM trainer_contexts tc
|
||||||
|
LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id
|
||||||
|
LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id
|
||||||
|
LEFT JOIN training_types tt ON tc.training_type_id = tt.id
|
||||||
|
WHERE tc.id = %s
|
||||||
|
""", (context_id,))
|
||||||
|
|
||||||
|
return r2d(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/trainer-contexts/{context_id}")
|
||||||
|
def delete_trainer_context(context_id: int, session=Depends(require_auth)):
|
||||||
|
"""Delete trainer context (own contexts only)."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Kontext nicht gefunden")
|
||||||
|
if row['profile_id'] != profile_id:
|
||||||
|
raise HTTPException(403, "Zugriff verweigert")
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM trainer_contexts WHERE id = %s", (context_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.4.0"
|
APP_VERSION = "0.5.0"
|
||||||
BUILD_DATE = "2026-04-23"
|
BUILD_DATE = "2026-04-23"
|
||||||
DB_SCHEMA_VERSION = "20260423"
|
DB_SCHEMA_VERSION = "20260423002"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -11,17 +11,30 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "0.4.0", # Updated: M:N API-Integration
|
"exercises": "0.5.0", # Updated: M:N Training Characters (Migration 012)
|
||||||
"training_units": "0.1.0",
|
"training_units": "0.1.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.1.0",
|
"planning": "0.1.0",
|
||||||
"import_wiki": "0.1.0",
|
"import_wiki": "0.1.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
"catalogs": "1.4.0", # Updated: Backend SQL Queries für renamed tables (Migration 010+011)
|
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.5.0",
|
||||||
|
"date": "2026-04-23",
|
||||||
|
"changes": [
|
||||||
|
"Feature: Trainer-Kontext-System für fokussierte Ansichten",
|
||||||
|
"DB: Migration 012 - exercise_training_characters (M:N)",
|
||||||
|
"DB: Migration 012 - trainer_contexts (Trainer-Profil-System)",
|
||||||
|
"Backend: CRUD API für Trainer-Kontexte (/api/trainer-contexts)",
|
||||||
|
"Frontend: TrainerContextsPage für Verwaltung eigener Arbeitsbereiche",
|
||||||
|
"Architektur: Flat Catalogs + Smart Filtering für 1000+ Übungen",
|
||||||
|
"Architektur: NULL = 'für alles geeignet', M:N = spezifische Zuordnung",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"date": "2026-04-23",
|
"date": "2026-04-23",
|
||||||
|
|
|
||||||
387
frontend/src/pages/TrainerContextsPage.jsx
Normal file
387
frontend/src/pages/TrainerContextsPage.jsx
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
export default function TrainerContextsPage() {
|
||||||
|
const [contexts, setContexts] = useState([])
|
||||||
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
const [styleDirections, setStyleDirections] = useState([])
|
||||||
|
const [trainingTypes, setTrainingTypes] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Edit/Create state
|
||||||
|
const [editingContext, setEditingContext] = useState(null)
|
||||||
|
const [newContext, setNewContext] = useState({
|
||||||
|
name: '',
|
||||||
|
focus_area_id: null,
|
||||||
|
style_direction_id: null,
|
||||||
|
training_type_id: null,
|
||||||
|
is_style_independent: false,
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [ctx, fa, sd, tt] = await Promise.all([
|
||||||
|
api.listTrainerContexts(),
|
||||||
|
api.listFocusAreas(),
|
||||||
|
api.listStyleDirections(),
|
||||||
|
api.listTrainingTypes()
|
||||||
|
])
|
||||||
|
setContexts(ctx)
|
||||||
|
setFocusAreas(fa)
|
||||||
|
setStyleDirections(sd)
|
||||||
|
setTrainingTypes(tt)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createContext() {
|
||||||
|
if (!newContext.name) {
|
||||||
|
setError('Name ist Pflichtfeld')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.createTrainerContext(newContext)
|
||||||
|
setNewContext({
|
||||||
|
name: '',
|
||||||
|
focus_area_id: null,
|
||||||
|
style_direction_id: null,
|
||||||
|
training_type_id: null,
|
||||||
|
is_style_independent: false,
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateContext(id, data) {
|
||||||
|
try {
|
||||||
|
await api.updateTrainerContext(id, data)
|
||||||
|
setEditingContext(null)
|
||||||
|
loadData()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContext(id) {
|
||||||
|
if (!confirm('Trainer-Kontext wirklich löschen?')) return
|
||||||
|
try {
|
||||||
|
await api.deleteTrainerContext(id)
|
||||||
|
loadData()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter style directions by selected focus area
|
||||||
|
function getFilteredStyleDirections(focusAreaId) {
|
||||||
|
if (!focusAreaId) return []
|
||||||
|
return styleDirections.filter(sd => sd.focus_area_id === parseInt(focusAreaId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h1>Meine Trainer-Bereiche</h1>
|
||||||
|
<p style={{ color: 'var(--text2)', marginBottom: '32px' }}>
|
||||||
|
Definiere deine Tätigkeitsbereiche für fokussierte Ansichten und Filter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create New Context */}
|
||||||
|
<div className="card" style={{ marginBottom: '32px' }}>
|
||||||
|
<h3>Neuer Trainer-Bereich</h3>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={newContext.name}
|
||||||
|
onChange={e => setNewContext({ ...newContext, name: e.target.value })}
|
||||||
|
placeholder="z.B. Karate Goju-Ryu Breitensport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Fokusbereich</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={newContext.focus_area_id || ''}
|
||||||
|
onChange={e => setNewContext({
|
||||||
|
...newContext,
|
||||||
|
focus_area_id: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
style_direction_id: null // Reset style when focus changes
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle Fokusbereiche -</option>
|
||||||
|
{focusAreas.map(fa => (
|
||||||
|
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newContext.focus_area_id && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Stilrichtung</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={newContext.style_direction_id || ''}
|
||||||
|
onChange={e => setNewContext({
|
||||||
|
...newContext,
|
||||||
|
style_direction_id: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle Stilrichtungen -</option>
|
||||||
|
{getFilteredStyleDirections(newContext.focus_area_id).map(sd => (
|
||||||
|
<option key={sd.id} value={sd.id}>{sd.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Trainingsstil</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={newContext.training_type_id || ''}
|
||||||
|
onChange={e => setNewContext({
|
||||||
|
...newContext,
|
||||||
|
training_type_id: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle Trainingsstile -</option>
|
||||||
|
{trainingTypes.map(tt => (
|
||||||
|
<option key={tt.id} value={tt.id}>{tt.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newContext.is_style_independent}
|
||||||
|
onChange={e => setNewContext({ ...newContext, is_style_independent: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Stilrichtungsunabhängig (z.B. Leistungssport Kumite für alle Stilrichtungen)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={newContext.description}
|
||||||
|
onChange={e => setNewContext({ ...newContext, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Optionale Beschreibung..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-primary" onClick={createContext}>Anlegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of Contexts */}
|
||||||
|
<div style={{ display: 'grid', gap: '16px' }}>
|
||||||
|
{loading && <p>Laden...</p>}
|
||||||
|
|
||||||
|
{!loading && contexts.length === 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ margin: 0, color: 'var(--text2)' }}>
|
||||||
|
Noch keine Trainer-Bereiche definiert. Lege oben deinen ersten Bereich an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contexts.map(ctx => (
|
||||||
|
<div key={ctx.id} className="card">
|
||||||
|
{editingContext?.id === ctx.id ? (
|
||||||
|
// Edit Mode
|
||||||
|
<div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={editingContext.name}
|
||||||
|
onChange={e => setEditingContext({ ...editingContext, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Fokusbereich</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={editingContext.focus_area_id || ''}
|
||||||
|
onChange={e => setEditingContext({
|
||||||
|
...editingContext,
|
||||||
|
focus_area_id: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
style_direction_id: null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle -</option>
|
||||||
|
{focusAreas.map(fa => (
|
||||||
|
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingContext.focus_area_id && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Stilrichtung</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={editingContext.style_direction_id || ''}
|
||||||
|
onChange={e => setEditingContext({
|
||||||
|
...editingContext,
|
||||||
|
style_direction_id: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle -</option>
|
||||||
|
{getFilteredStyleDirections(editingContext.focus_area_id).map(sd => (
|
||||||
|
<option key={sd.id} value={sd.id}>{sd.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Trainingsstil</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={editingContext.training_type_id || ''}
|
||||||
|
onChange={e => setEditingContext({
|
||||||
|
...editingContext,
|
||||||
|
training_type_id: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="">- Alle -</option>
|
||||||
|
{trainingTypes.map(tt => (
|
||||||
|
<option key={tt.id} value={tt.id}>{tt.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingContext.is_style_independent}
|
||||||
|
onChange={e => setEditingContext({ ...editingContext, is_style_independent: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Stilrichtungsunabhängig
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={editingContext.description || ''}
|
||||||
|
onChange={e => setEditingContext({ ...editingContext, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="btn btn-primary" onClick={() => updateContext(ctx.id, editingContext)}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => setEditingContext(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display Mode
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0' }}>
|
||||||
|
{ctx.name}
|
||||||
|
{!ctx.is_active && (
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text3)' }}>
|
||||||
|
(inaktiv)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--text2)', marginBottom: '4px' }}>
|
||||||
|
{ctx.focus_area_name && (
|
||||||
|
<span>
|
||||||
|
{ctx.focus_area_icon} {ctx.focus_area_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!ctx.focus_area_name && <span>Alle Fokusbereiche</span>}
|
||||||
|
|
||||||
|
{ctx.style_direction_name && (
|
||||||
|
<span> → {ctx.style_direction_name}</span>
|
||||||
|
)}
|
||||||
|
{ctx.focus_area_name && !ctx.style_direction_name && !ctx.is_style_independent && (
|
||||||
|
<span> → Alle Stilrichtungen</span>
|
||||||
|
)}
|
||||||
|
{ctx.is_style_independent && (
|
||||||
|
<span style={{ color: 'var(--accent)' }}> → Stilunabhängig</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ctx.training_type_name && (
|
||||||
|
<span> → {ctx.training_type_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ctx.description && (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: '8px 0 0 0' }}>
|
||||||
|
{ctx.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setEditingContext(ctx)}
|
||||||
|
style={{ padding: '4px 12px', fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => deleteContext(ctx.id)}
|
||||||
|
style={{ padding: '4px 12px', fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -423,6 +423,29 @@ export async function getStyleDirectionsHierarchy(filters = {}) {
|
||||||
return request(`/api/training-styles/hierarchy${query ? '?' + query : ''}`)
|
return request(`/api/training-styles/hierarchy${query ? '?' + query : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trainer Contexts (Fokussierte Ansichten)
|
||||||
|
export async function listTrainerContexts() {
|
||||||
|
return request('/api/trainer-contexts')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrainerContext(data) {
|
||||||
|
return request('/api/trainer-contexts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTrainerContext(id, data) {
|
||||||
|
return request(`/api/trainer-contexts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrainerContext(id) {
|
||||||
|
return request(`/api/trainer-contexts/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Training Planning
|
// Training Planning
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -556,6 +579,10 @@ export const api = {
|
||||||
updateStyleDirectionTargetGroup,
|
updateStyleDirectionTargetGroup,
|
||||||
deleteStyleDirectionTargetGroup,
|
deleteStyleDirectionTargetGroup,
|
||||||
getStyleDirectionsHierarchy,
|
getStyleDirectionsHierarchy,
|
||||||
|
listTrainerContexts,
|
||||||
|
createTrainerContext,
|
||||||
|
updateTrainerContext,
|
||||||
|
deleteTrainerContext,
|
||||||
|
|
||||||
// System
|
// System
|
||||||
getVersion,
|
getVersion,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.4.0"
|
export const APP_VERSION = "0.5.0"
|
||||||
export const BUILD_DATE = "2026-04-23"
|
export const BUILD_DATE = "2026-04-23"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
|
|
@ -12,4 +12,5 @@ export const PAGE_VERSIONS = {
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "1.0.0",
|
TrainingPlanningPage: "1.0.0",
|
||||||
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables
|
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables
|
||||||
|
TrainerContextsPage: "1.0.0", // New: Trainer-Kontext-Verwaltung
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user