feat(v9d): add training types system + logout button
Phase 1: Training Types Basis ============================= Backend: - Migration 004: training_types table + seed data (24 types) - New router: /api/training-types (grouped, flat, categories) - Extend activity_log: training_type_id, training_category, training_subcategory - Extend ActivityEntry model: support training type fields Frontend: - TrainingTypeSelect component (two-level dropdown) - TrainingTypeDistribution component (pie chart) - API functions: listTrainingTypes, listTrainingTypesFlat, getTrainingCategories Quick Win: Logout Button ======================== - Add LogOut icon button in app header - Confirm dialog before logout - Redirect to / after logout - Hover effect: red color on hover Not yet integrated: - TrainingTypeSelect not yet in ActivityPage form - TrainingTypeDistribution not yet in Dashboard (will be added in next commit) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0aca5fda5d
commit
410b2ce308
|
|
@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper
|
||||||
from routers import activity, nutrition, photos, insights, prompts
|
from routers import activity, nutrition, photos, insights, prompts
|
||||||
from routers import admin, stats, exportdata, importdata
|
from routers import admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||||
from routers import user_restrictions, access_grants
|
from routers import user_restrictions, access_grants, training_types
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||||
|
|
@ -85,6 +85,9 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
|
||||||
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
||||||
app.include_router(access_grants.router) # /api/access-grants (admin)
|
app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||||
|
|
||||||
|
# v9d Training Types
|
||||||
|
app.include_router(training_types.router) # /api/training-types/*
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
|
|
|
||||||
86
backend/migrations/004_training_types.sql
Normal file
86
backend/migrations/004_training_types.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
-- Migration 004: Training Types & Categories
|
||||||
|
-- Part of v9d: Schlaf + Sport-Vertiefung
|
||||||
|
-- Created: 2026-03-21
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 1. Create training_types table
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS training_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc.
|
||||||
|
subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc.
|
||||||
|
name_de VARCHAR(100) NOT NULL, -- German display name
|
||||||
|
name_en VARCHAR(100) NOT NULL, -- English display name
|
||||||
|
icon VARCHAR(10), -- Emoji icon
|
||||||
|
sort_order INTEGER DEFAULT 0, -- For UI ordering
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 2. Add training type columns to activity_log
|
||||||
|
-- ========================================
|
||||||
|
ALTER TABLE activity_log
|
||||||
|
ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries
|
||||||
|
ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 3. Create indexes
|
||||||
|
-- ========================================
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 4. Seed training types data
|
||||||
|
-- ========================================
|
||||||
|
|
||||||
|
-- Cardio (Ausdauer)
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('cardio', 'running', 'Laufen', 'Running', '🏃', 100),
|
||||||
|
('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101),
|
||||||
|
('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102),
|
||||||
|
('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103),
|
||||||
|
('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104);
|
||||||
|
|
||||||
|
-- Kraft
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200),
|
||||||
|
('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201),
|
||||||
|
('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202),
|
||||||
|
('strength', 'functional', 'Funktionell', 'Functional', '⚡', 203);
|
||||||
|
|
||||||
|
-- Schnellkraft / HIIT
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300),
|
||||||
|
('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301),
|
||||||
|
('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302);
|
||||||
|
|
||||||
|
-- Kampfsport / Technikkraft
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400),
|
||||||
|
('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401),
|
||||||
|
('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402);
|
||||||
|
|
||||||
|
-- Mobility & Dehnung
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500),
|
||||||
|
('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501),
|
||||||
|
('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502),
|
||||||
|
('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503);
|
||||||
|
|
||||||
|
-- Erholung (aktiv)
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600),
|
||||||
|
('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601),
|
||||||
|
('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602);
|
||||||
|
|
||||||
|
-- General / Uncategorized
|
||||||
|
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||||
|
('other', NULL, 'Sonstiges', 'Other', '📝', 900);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 5. Add comment
|
||||||
|
-- ========================================
|
||||||
|
COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories';
|
||||||
|
COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization';
|
||||||
|
|
@ -84,6 +84,9 @@ class ActivityEntry(BaseModel):
|
||||||
rpe: Optional[int] = None
|
rpe: Optional[int] = None
|
||||||
source: Optional[str] = 'manual'
|
source: Optional[str] = 'manual'
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
training_type_id: Optional[int] = None # v9d: Training type categorization
|
||||||
|
training_category: Optional[str] = None # v9d: Denormalized category
|
||||||
|
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
||||||
|
|
||||||
|
|
||||||
class NutritionDay(BaseModel):
|
class NutritionDay(BaseModel):
|
||||||
|
|
|
||||||
123
backend/routers/training_types.py
Normal file
123
backend/routers/training_types.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""
|
||||||
|
Training Types API - v9d
|
||||||
|
|
||||||
|
Provides hierarchical list of training categories and subcategories
|
||||||
|
for activity classification.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
from auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/training-types", tags=["training-types"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_training_types(session: dict = Depends(require_auth)):
|
||||||
|
"""
|
||||||
|
Get all training types, grouped by category.
|
||||||
|
|
||||||
|
Returns hierarchical structure:
|
||||||
|
{
|
||||||
|
"cardio": [
|
||||||
|
{"id": 1, "subcategory": "running", "name_de": "Laufen", ...},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"strength": [...],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, category, subcategory, name_de, name_en, icon, sort_order
|
||||||
|
FROM training_types
|
||||||
|
ORDER BY sort_order, category, subcategory
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Group by category
|
||||||
|
grouped = {}
|
||||||
|
for row in rows:
|
||||||
|
cat = row['category']
|
||||||
|
if cat not in grouped:
|
||||||
|
grouped[cat] = []
|
||||||
|
grouped[cat].append({
|
||||||
|
'id': row['id'],
|
||||||
|
'category': row['category'],
|
||||||
|
'subcategory': row['subcategory'],
|
||||||
|
'name_de': row['name_de'],
|
||||||
|
'name_en': row['name_en'],
|
||||||
|
'icon': row['icon'],
|
||||||
|
'sort_order': row['sort_order']
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flat")
|
||||||
|
def list_training_types_flat(session: dict = Depends(require_auth)):
|
||||||
|
"""
|
||||||
|
Get all training types as flat list (for simple dropdown).
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, category, subcategory, name_de, name_en, icon
|
||||||
|
FROM training_types
|
||||||
|
ORDER BY sort_order
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
def list_categories(session: dict = Depends(require_auth)):
|
||||||
|
"""
|
||||||
|
Get list of unique categories with metadata.
|
||||||
|
"""
|
||||||
|
categories = {
|
||||||
|
'cardio': {
|
||||||
|
'name_de': 'Cardio (Ausdauer)',
|
||||||
|
'name_en': 'Cardio (Endurance)',
|
||||||
|
'icon': '❤️',
|
||||||
|
'color': '#EF4444'
|
||||||
|
},
|
||||||
|
'strength': {
|
||||||
|
'name_de': 'Kraft',
|
||||||
|
'name_en': 'Strength',
|
||||||
|
'icon': '💪',
|
||||||
|
'color': '#3B82F6'
|
||||||
|
},
|
||||||
|
'hiit': {
|
||||||
|
'name_de': 'Schnellkraft / HIIT',
|
||||||
|
'name_en': 'Power / HIIT',
|
||||||
|
'icon': '🔥',
|
||||||
|
'color': '#F59E0B'
|
||||||
|
},
|
||||||
|
'martial_arts': {
|
||||||
|
'name_de': 'Kampfsport',
|
||||||
|
'name_en': 'Martial Arts',
|
||||||
|
'icon': '🥋',
|
||||||
|
'color': '#8B5CF6'
|
||||||
|
},
|
||||||
|
'mobility': {
|
||||||
|
'name_de': 'Mobility & Dehnung',
|
||||||
|
'name_en': 'Mobility & Stretching',
|
||||||
|
'icon': '🧘',
|
||||||
|
'color': '#10B981'
|
||||||
|
},
|
||||||
|
'recovery': {
|
||||||
|
'name_de': 'Erholung (aktiv)',
|
||||||
|
'name_en': 'Recovery (active)',
|
||||||
|
'icon': '💆',
|
||||||
|
'color': '#6B7280'
|
||||||
|
},
|
||||||
|
'other': {
|
||||||
|
'name_de': 'Sonstiges',
|
||||||
|
'name_en': 'Other',
|
||||||
|
'icon': '📝',
|
||||||
|
'color': '#9CA3AF'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return categories
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react'
|
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
|
||||||
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import { setProfileId } from './utils/api'
|
import { setProfileId } from './utils/api'
|
||||||
|
|
@ -50,10 +50,17 @@ function Nav() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { session, loading: authLoading, needsSetup } = useAuth()
|
const { session, loading: authLoading, needsSetup, logout } = useAuth()
|
||||||
const { activeProfile, loading: profileLoading } = useProfile()
|
const { activeProfile, loading: profileLoading } = useProfile()
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (confirm('Wirklich abmelden?')) {
|
||||||
|
logout()
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if (session?.profile_id) {
|
if (session?.profile_id) {
|
||||||
setProfileId(session.profile_id)
|
setProfileId(session.profile_id)
|
||||||
|
|
@ -119,12 +126,32 @@ function AppShell() {
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<span className="app-logo">Mitai Jinkendo</span>
|
<span className="app-logo">Mitai Jinkendo</span>
|
||||||
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
<div style={{display:'flex', gap:12, alignItems:'center'}}>
|
||||||
{activeProfile
|
<button
|
||||||
? <Avatar profile={activeProfile} size={30}/>
|
onClick={handleLogout}
|
||||||
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
title="Abmelden"
|
||||||
}
|
style={{
|
||||||
</NavLink>
|
background:'none',
|
||||||
|
border:'none',
|
||||||
|
cursor:'pointer',
|
||||||
|
padding:6,
|
||||||
|
display:'flex',
|
||||||
|
alignItems:'center',
|
||||||
|
color:'var(--text2)',
|
||||||
|
transition:'color 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'}
|
||||||
|
>
|
||||||
|
<LogOut size={18}/>
|
||||||
|
</button>
|
||||||
|
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
||||||
|
{activeProfile
|
||||||
|
? <Avatar profile={activeProfile} size={30}/>
|
||||||
|
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
|
||||||
120
frontend/src/components/TrainingTypeDistribution.jsx
Normal file
120
frontend/src/components/TrainingTypeDistribution.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrainingTypeDistribution - Pie chart showing activity distribution by type
|
||||||
|
*
|
||||||
|
* @param {number} days - Number of days to analyze (default: 28)
|
||||||
|
*/
|
||||||
|
export default function TrainingTypeDistribution({ days = 28 }) {
|
||||||
|
const [data, setData] = useState([])
|
||||||
|
const [categories, setCategories] = useState({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.listActivity(days),
|
||||||
|
api.getTrainingCategories()
|
||||||
|
]).then(([activities, cats]) => {
|
||||||
|
setCategories(cats)
|
||||||
|
|
||||||
|
// Group by training_category
|
||||||
|
const grouped = {}
|
||||||
|
activities.forEach(act => {
|
||||||
|
const cat = act.training_category || 'other'
|
||||||
|
if (!grouped[cat]) grouped[cat] = 0
|
||||||
|
grouped[cat]++
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to chart data
|
||||||
|
const chartData = Object.entries(grouped).map(([cat, count]) => ({
|
||||||
|
name: cats[cat]?.name_de || 'Sonstiges',
|
||||||
|
value: count,
|
||||||
|
color: cats[cat]?.color || '#9CA3AF',
|
||||||
|
icon: cats[cat]?.icon || '📝'
|
||||||
|
}))
|
||||||
|
|
||||||
|
setData(chartData.sort((a, b) => b.value - a.value))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to load training type distribution:', err)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{textAlign:'center', padding:20}}>
|
||||||
|
<div className="spinner" style={{width:24, height:24, margin:'0 auto'}}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:13}}>
|
||||||
|
Keine Aktivitäten in den letzten {days} Tagen
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data.reduce((sum, d) => sum + d.value, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => `${value} Einheiten (${Math.round(value/total*100)}%)`}
|
||||||
|
contentStyle={{
|
||||||
|
background:'var(--surface)',
|
||||||
|
border:'1px solid var(--border)',
|
||||||
|
borderRadius:8,
|
||||||
|
fontSize:12
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:12}}>
|
||||||
|
{data.map((entry, i) => (
|
||||||
|
<div key={i} style={{display:'flex', alignItems:'center', gap:6, fontSize:12}}>
|
||||||
|
<div style={{
|
||||||
|
width:12, height:12, borderRadius:'50%',
|
||||||
|
background:entry.color, flexShrink:0
|
||||||
|
}}/>
|
||||||
|
<span style={{color:'var(--text2)'}}>
|
||||||
|
{entry.icon} {entry.name}
|
||||||
|
</span>
|
||||||
|
<span style={{marginLeft:'auto', fontWeight:600, color:'var(--text1)'}}>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop:12, padding:'8px 12px',
|
||||||
|
background:'var(--surface)', borderRadius:8,
|
||||||
|
fontSize:12, color:'var(--text3)', textAlign:'center'
|
||||||
|
}}>
|
||||||
|
Gesamt: <strong style={{color:'var(--text1)'}}>{total}</strong> Einheiten in {days} Tagen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/components/TrainingTypeSelect.jsx
Normal file
114
frontend/src/components/TrainingTypeSelect.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrainingTypeSelect - Two-level dropdown for training type selection
|
||||||
|
*
|
||||||
|
* @param {number|null} value - Selected training_type_id
|
||||||
|
* @param {function} onChange - Callback (training_type_id, category, subcategory) => void
|
||||||
|
* @param {boolean} required - Is selection required?
|
||||||
|
*/
|
||||||
|
export default function TrainingTypeSelect({ value, onChange, required = false }) {
|
||||||
|
const [types, setTypes] = useState({}) // Grouped by category
|
||||||
|
const [categories, setCategories] = useState({}) // Category metadata
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.listTrainingTypes(),
|
||||||
|
api.getTrainingCategories()
|
||||||
|
]).then(([typesData, catsData]) => {
|
||||||
|
setTypes(typesData)
|
||||||
|
setCategories(catsData)
|
||||||
|
|
||||||
|
// If value is set, find and select the category
|
||||||
|
if (value) {
|
||||||
|
for (const cat in typesData) {
|
||||||
|
const found = typesData[cat].find(t => t.id === value)
|
||||||
|
if (found) {
|
||||||
|
setSelectedCategory(cat)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to load training types:', err)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleCategoryChange = (cat) => {
|
||||||
|
setSelectedCategory(cat)
|
||||||
|
// Auto-select first subcategory if available
|
||||||
|
if (types[cat] && types[cat].length > 0) {
|
||||||
|
const firstType = types[cat][0]
|
||||||
|
onChange(firstType.id, firstType.category, firstType.subcategory)
|
||||||
|
} else {
|
||||||
|
onChange(null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTypeChange = (typeId) => {
|
||||||
|
const type = Object.values(types)
|
||||||
|
.flat()
|
||||||
|
.find(t => t.id === parseInt(typeId))
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
onChange(type.id, type.category, type.subcategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{fontSize:13, color:'var(--text3)'}}>Lade Trainingstypen...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableCategories = Object.keys(categories)
|
||||||
|
const availableTypes = selectedCategory ? (types[selectedCategory] || []) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', gap:8, flexDirection:'column'}}>
|
||||||
|
{/* Category dropdown */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Kategorie</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
style={{width:'100%', boxSizing:'border-box'}}
|
||||||
|
>
|
||||||
|
<option value="">-- Wähle Kategorie --</option>
|
||||||
|
{availableCategories.map(cat => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{categories[cat].icon} {categories[cat].name_de}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subcategory dropdown (conditional) */}
|
||||||
|
{selectedCategory && availableTypes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Untertyp</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleTypeChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
style={{width:'100%', boxSizing:'border-box'}}
|
||||||
|
>
|
||||||
|
<option value="">-- Wähle Untertyp --</option>
|
||||||
|
{availableTypes.map(type => (
|
||||||
|
<option key={type.id} value={type.id}>
|
||||||
|
{type.icon} {type.name_de}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -189,4 +189,9 @@ export const api = {
|
||||||
createAccessGrant: (d) => req('/access-grants',json(d)),
|
createAccessGrant: (d) => req('/access-grants',json(d)),
|
||||||
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
||||||
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
||||||
|
|
||||||
|
// v9d: Training Types
|
||||||
|
listTrainingTypes: () => req('/training-types'), // Grouped by category
|
||||||
|
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
|
||||||
|
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user