feat: admin CRUD for training types + distribution chart in ActivityPage
Backend (v9d Phase 1b): - Migration 006: Add abilities JSONB column + descriptions - admin_training_types.py: Full CRUD endpoints for training types - List, Get, Create, Update, Delete - Abilities taxonomy endpoint (5 dimensions: koordinativ, konditionell, kognitiv, psychisch, taktisch) - Validation: Cannot delete types in use - Register admin_training_types router in main.py Frontend: - AdminTrainingTypesPage: Full CRUD UI - Create/edit form with all fields (category, subcategory, names, icon, descriptions, sort_order) - List grouped by category with color coding - Delete with usage check - Note about abilities mapping coming in v9f - Add TrainingTypeDistribution to ActivityPage stats tab - Add admin link in AdminPanel (v9d section) - Update api.js with admin training types methods Notes: - Abilities mapping UI deferred to v9f (flexible prompt system) - Placeholders (abilities column) in place for future AI analysis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d164ab932d
commit
eecc00e824
|
|
@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper
|
|||
from routers import activity, nutrition, photos, insights, prompts
|
||||
from routers import admin, stats, exportdata, importdata
|
||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||
from routers import user_restrictions, access_grants, training_types
|
||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -86,7 +86,8 @@ app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
|||
app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||
|
||||
# v9d Training Types
|
||||
app.include_router(training_types.router) # /api/training-types/*
|
||||
app.include_router(training_types.router) # /api/training-types/*
|
||||
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
29
backend/migrations/006_training_types_abilities.sql
Normal file
29
backend/migrations/006_training_types_abilities.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration 006: Training Types - Abilities Mapping
|
||||
-- Add abilities JSONB column for future AI analysis
|
||||
-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch
|
||||
-- Created: 2026-03-21
|
||||
|
||||
-- ========================================
|
||||
-- Add abilities column
|
||||
-- ========================================
|
||||
ALTER TABLE training_types
|
||||
ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}';
|
||||
|
||||
-- ========================================
|
||||
-- Add description columns for better documentation
|
||||
-- ========================================
|
||||
ALTER TABLE training_types
|
||||
ADD COLUMN IF NOT EXISTS description_de TEXT,
|
||||
ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
|
||||
-- ========================================
|
||||
-- Add index for abilities queries
|
||||
-- ========================================
|
||||
CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities);
|
||||
|
||||
-- ========================================
|
||||
-- Comment
|
||||
-- ========================================
|
||||
COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)';
|
||||
COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context';
|
||||
COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context';
|
||||
281
backend/routers/admin_training_types.py
Normal file
281
backend/routers/admin_training_types.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
Admin Training Types Management - v9d Phase 1b
|
||||
|
||||
CRUD operations for training types with abilities mapping preparation.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrainingTypeCreate(BaseModel):
|
||||
category: str
|
||||
subcategory: Optional[str] = None
|
||||
name_de: str
|
||||
name_en: str
|
||||
icon: Optional[str] = None
|
||||
description_de: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
abilities: Optional[dict] = None
|
||||
|
||||
|
||||
class TrainingTypeUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
subcategory: Optional[str] = None
|
||||
name_de: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description_de: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
abilities: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_training_types_admin(session: dict = Depends(require_admin)):
|
||||
"""
|
||||
Get all training types for admin management.
|
||||
Returns full details including abilities.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities,
|
||||
created_at
|
||||
FROM training_types
|
||||
ORDER BY sort_order, category, subcategory
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{type_id}")
|
||||
def get_training_type(type_id: int, session: dict = Depends(require_admin)):
|
||||
"""Get single training type by ID."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities,
|
||||
created_at
|
||||
FROM training_types
|
||||
WHERE id = %s
|
||||
""", (type_id,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)):
|
||||
"""Create new training type."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Convert abilities dict to JSONB
|
||||
abilities_json = data.abilities if data.abilities else {}
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO training_types
|
||||
(category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.category,
|
||||
data.subcategory,
|
||||
data.name_de,
|
||||
data.name_en,
|
||||
data.icon,
|
||||
data.description_de,
|
||||
data.description_en,
|
||||
data.sort_order,
|
||||
abilities_json
|
||||
))
|
||||
|
||||
new_id = cur.fetchone()['id']
|
||||
|
||||
logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})")
|
||||
|
||||
return {"id": new_id, "message": "Training type created"}
|
||||
|
||||
|
||||
@router.put("/{type_id}")
|
||||
def update_training_type(
|
||||
type_id: int,
|
||||
data: TrainingTypeUpdate,
|
||||
session: dict = Depends(require_admin)
|
||||
):
|
||||
"""Update existing training type."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
if data.category is not None:
|
||||
updates.append("category = %s")
|
||||
values.append(data.category)
|
||||
if data.subcategory is not None:
|
||||
updates.append("subcategory = %s")
|
||||
values.append(data.subcategory)
|
||||
if data.name_de is not None:
|
||||
updates.append("name_de = %s")
|
||||
values.append(data.name_de)
|
||||
if data.name_en is not None:
|
||||
updates.append("name_en = %s")
|
||||
values.append(data.name_en)
|
||||
if data.icon is not None:
|
||||
updates.append("icon = %s")
|
||||
values.append(data.icon)
|
||||
if data.description_de is not None:
|
||||
updates.append("description_de = %s")
|
||||
values.append(data.description_de)
|
||||
if data.description_en is not None:
|
||||
updates.append("description_en = %s")
|
||||
values.append(data.description_en)
|
||||
if data.sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
values.append(data.sort_order)
|
||||
if data.abilities is not None:
|
||||
updates.append("abilities = %s")
|
||||
values.append(data.abilities)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
values.append(type_id)
|
||||
|
||||
cur.execute(f"""
|
||||
UPDATE training_types
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
""", values)
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
logger.info(f"[ADMIN] Training type updated: {type_id}")
|
||||
|
||||
return {"id": type_id, "message": "Training type updated"}
|
||||
|
||||
|
||||
@router.delete("/{type_id}")
|
||||
def delete_training_type(type_id: int, session: dict = Depends(require_admin)):
|
||||
"""
|
||||
Delete training type.
|
||||
|
||||
WARNING: This will fail if any activities reference this type.
|
||||
Consider adding a soft-delete or archive mechanism if needed.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if any activities use this type
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM activity_log
|
||||
WHERE training_type_id = %s
|
||||
""", (type_id,))
|
||||
|
||||
count = cur.fetchone()['count']
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot delete: {count} activities are using this training type. "
|
||||
"Please reassign or delete those activities first."
|
||||
)
|
||||
|
||||
cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,))
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
logger.info(f"[ADMIN] Training type deleted: {type_id}")
|
||||
|
||||
return {"message": "Training type deleted"}
|
||||
|
||||
|
||||
@router.get("/taxonomy/abilities")
|
||||
def get_abilities_taxonomy(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get abilities taxonomy for UI and AI analysis.
|
||||
|
||||
This defines the 5 dimensions of athletic development.
|
||||
"""
|
||||
taxonomy = {
|
||||
"koordinativ": {
|
||||
"name_de": "Koordinative Fähigkeiten",
|
||||
"name_en": "Coordination Abilities",
|
||||
"icon": "🎯",
|
||||
"abilities": [
|
||||
{"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"},
|
||||
{"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"},
|
||||
{"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"},
|
||||
{"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"},
|
||||
{"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"},
|
||||
{"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"},
|
||||
{"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"}
|
||||
]
|
||||
},
|
||||
"konditionell": {
|
||||
"name_de": "Konditionelle Fähigkeiten",
|
||||
"name_en": "Conditional Abilities",
|
||||
"icon": "💪",
|
||||
"abilities": [
|
||||
{"key": "kraft", "name_de": "Kraft", "name_en": "Strength"},
|
||||
{"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"},
|
||||
{"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"},
|
||||
{"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"}
|
||||
]
|
||||
},
|
||||
"kognitiv": {
|
||||
"name_de": "Kognitive Fähigkeiten",
|
||||
"name_en": "Cognitive Abilities",
|
||||
"icon": "🧠",
|
||||
"abilities": [
|
||||
{"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"},
|
||||
{"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"},
|
||||
{"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"},
|
||||
{"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"}
|
||||
]
|
||||
},
|
||||
"psychisch": {
|
||||
"name_de": "Psychische Fähigkeiten",
|
||||
"name_en": "Psychological Abilities",
|
||||
"icon": "🎭",
|
||||
"abilities": [
|
||||
{"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"},
|
||||
{"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"},
|
||||
{"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"},
|
||||
{"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"}
|
||||
]
|
||||
},
|
||||
"taktisch": {
|
||||
"name_de": "Taktische Fähigkeiten",
|
||||
"name_en": "Tactical Abilities",
|
||||
"icon": "♟️",
|
||||
"abilities": [
|
||||
{"key": "timing", "name_de": "Timing", "name_en": "Timing"},
|
||||
{"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"},
|
||||
{"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"},
|
||||
{"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return taxonomy
|
||||
|
|
@ -27,6 +27,7 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
|||
import AdminTiersPage from './pages/AdminTiersPage'
|
||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import './app.css'
|
||||
|
||||
|
|
@ -172,6 +173,7 @@ function AppShell() {
|
|||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { api } from '../utils/api'
|
|||
import UsageBadge from '../components/UsageBadge'
|
||||
import TrainingTypeSelect from '../components/TrainingTypeSelect'
|
||||
import BulkCategorize from '../components/BulkCategorize'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -302,6 +303,11 @@ export default function ActivityPage() {
|
|||
|
||||
{tab==='stats' && stats && (
|
||||
<div>
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">🏋️ Trainingstyp-Verteilung (30 Tage)</div>
|
||||
<TrainingTypeDistribution days={30} />
|
||||
</div>
|
||||
|
||||
{chartData.length>=2 && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Aktive Kalorien pro Tag</div>
|
||||
|
|
|
|||
|
|
@ -424,6 +424,23 @@ export default function AdminPanel() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v9d Training Types Management */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/training-types">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🏋️ Trainingstypen verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
384
frontend/src/pages/AdminTrainingTypesPage.jsx
Normal file
384
frontend/src/pages/AdminTrainingTypesPage.jsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* AdminTrainingTypesPage - CRUD for training types
|
||||
* v9d Phase 1b - Basic CRUD without abilities mapping
|
||||
*/
|
||||
export default function AdminTrainingTypesPage() {
|
||||
const nav = useNavigate()
|
||||
const [types, setTypes] = useState([])
|
||||
const [categories, setCategories] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [formData, setFormData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.adminListTrainingTypes(),
|
||||
api.getTrainingCategories()
|
||||
]).then(([typesData, catsData]) => {
|
||||
setTypes(typesData)
|
||||
setCategories(catsData)
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
console.error('Failed to load training types:', err)
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const startCreate = () => {
|
||||
setFormData({
|
||||
category: 'cardio',
|
||||
subcategory: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
sort_order: 0
|
||||
})
|
||||
setEditingId('new')
|
||||
}
|
||||
|
||||
const startEdit = (type) => {
|
||||
setFormData({
|
||||
category: type.category,
|
||||
subcategory: type.subcategory || '',
|
||||
name_de: type.name_de,
|
||||
name_en: type.name_en,
|
||||
icon: type.icon || '',
|
||||
description_de: type.description_de || '',
|
||||
description_en: type.description_en || '',
|
||||
sort_order: type.sort_order
|
||||
})
|
||||
setEditingId(type.id)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setFormData(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name_de || !formData.name_en) {
|
||||
setError('Name (DE) und Name (EN) sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (editingId === 'new') {
|
||||
await api.adminCreateTrainingType(formData)
|
||||
} else {
|
||||
await api.adminUpdateTrainingType(editingId, formData)
|
||||
}
|
||||
await load()
|
||||
cancelEdit()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.adminDeleteTrainingType(id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert('Löschen fehlgeschlagen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = {}
|
||||
types.forEach(type => {
|
||||
if (!grouped[type.category]) {
|
||||
grouped[type.category] = []
|
||||
}
|
||||
grouped[type.category].push(type)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 16px 80px' }}>
|
||||
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button
|
||||
onClick={() => nav('/settings')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
display: 'flex',
|
||||
color: 'var(--text2)'
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Trainingstypen verwalten</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new button */}
|
||||
{!editingId && (
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Neuen Trainingstyp anlegen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{editingId && formData && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
||||
{editingId === 'new' ? '➕ Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Kategorie *</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
>
|
||||
{Object.keys(categories).map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{categories[cat].icon} {categories[cat].name_de}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Subkategorie</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.subcategory}
|
||||
onChange={e => setFormData({ ...formData, subcategory: e.target.value })}
|
||||
placeholder="z.B. running, hypertrophy, meditation"
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Kleingeschrieben, ohne Leerzeichen, eindeutig
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Name (Deutsch) *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_de}
|
||||
onChange={e => setFormData({ ...formData, name_de: e.target.value })}
|
||||
placeholder="z.B. Laufen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Name (English) *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_en}
|
||||
onChange={e => setFormData({ ...formData, name_en: e.target.value })}
|
||||
placeholder="e.g. Running"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Icon (Emoji)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.icon}
|
||||
onChange={e => setFormData({ ...formData, icon: e.target.value })}
|
||||
placeholder="🏃"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.sort_order}
|
||||
onChange={e => setFormData({ ...formData, sort_order: parseInt(e.target.value) })}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Niedrigere Zahlen werden zuerst angezeigt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Beschreibung (Deutsch)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description_de}
|
||||
onChange={e => setFormData({ ...formData, description_de: e.target.value })}
|
||||
placeholder="Optional: Beschreibung für KI-Analyse"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Beschreibung (English)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description_en}
|
||||
onChange={e => setFormData({ ...formData, description_en: e.target.value })}
|
||||
placeholder="Optional: Description for AI analysis"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Speichere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} /> Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<X size={16} /> Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List grouped by category */}
|
||||
{Object.entries(grouped).sort((a, b) => {
|
||||
const orderA = categories[a[0]]?.sort_order || 999
|
||||
const orderB = categories[b[0]]?.sort_order || 999
|
||||
return orderA - orderB
|
||||
}).map(([cat, catTypes]) => (
|
||||
<div key={cat} className="card" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
color: categories[cat]?.color || 'var(--text1)'
|
||||
}}>
|
||||
{categories[cat]?.icon} {categories[cat]?.name_de}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{catTypes.sort((a, b) => a.sort_order - b.sort_order).map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 6
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18 }}>{type.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{type.name_de} <span style={{ color: 'var(--text3)' }}>/ {type.name_en}</span>
|
||||
</div>
|
||||
{type.subcategory && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Subkategorie: {type.subcategory}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEdit(type)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: 'var(--accent)'
|
||||
}}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(type.id, type.name_de)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: '#D85A30'
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{
|
||||
marginTop: 20,
|
||||
padding: 12,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
<strong>Hinweis:</strong> Das Fähigkeiten-Mapping (koordinativ, konditionell, etc.) wird in einer späteren Version hinzugefügt.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -196,4 +196,12 @@ export const api = {
|
|||
listTrainingTypes: () => req('/training-types'), // Grouped by category
|
||||
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
|
||||
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
|
||||
|
||||
// Admin: Training Types (v9d Phase 1b)
|
||||
adminListTrainingTypes: () => req('/admin/training-types'),
|
||||
adminGetTrainingType: (id) => req(`/admin/training-types/${id}`),
|
||||
adminCreateTrainingType: (d) => req('/admin/training-types', json(d)),
|
||||
adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)),
|
||||
adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}),
|
||||
getAbilitiesTaxonomy: () => req('/admin/training-types/taxonomy/abilities'),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user