feat: Visual Profile Builder integrated into Training Types page (#15)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

MAJOR UX IMPROVEMENT - No more JSON editing required!

New Component: ProfileBuilder.jsx
- Visual form for configuring training type profiles
- Parameter dropdown (dynamically loaded from API)
- Operator dropdown (>=, <=, >, <, =, ≠, between)
- Value input (type-aware, between shows min/max)
- Weight slider (1-10)
- Add/remove rules visually
- Pass strategy selection
- Optional checkbox per rule
- Expandable sections

Integration: AdminTrainingTypesPage.jsx
- Added ProfileBuilder component
- ⚙️ Settings icon per training type
- Opens visual form when clicked
- ✓ Profil badge shows configured types
- Loads 16 parameters from API
- Save directly to training type

User Experience:
1. Go to /admin/training-types
2. Click ⚙️ icon on any type
3. Visual form opens
4. Add rules via dropdowns
5. Save → Profile configured!

NO JSON EDITING NEEDED! 🎉

Next: Add visual builders for other dimensions (Zones, Effects, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 13:01:35 +01:00
parent 2abaac22cf
commit 6fa15f7f57
2 changed files with 470 additions and 3 deletions

View File

@ -0,0 +1,403 @@
import { useState, useEffect } from 'react'
import { Trash2, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import '../app.css'
const OPERATORS = [
{ value: 'gte', label: '≥ Größer gleich', types: ['integer', 'float'] },
{ value: 'lte', label: '≤ Kleiner gleich', types: ['integer', 'float'] },
{ value: 'gt', label: '> Größer', types: ['integer', 'float'] },
{ value: 'lt', label: '< Kleiner', types: ['integer', 'float'] },
{ value: 'eq', label: '= Gleich', types: ['integer', 'float', 'string'] },
{ value: 'neq', label: '≠ Ungleich', types: ['integer', 'float', 'string'] },
{ value: 'between', label: '⟷ Zwischen', types: ['integer', 'float'] },
]
const PASS_STRATEGIES = [
{ value: 'weighted_score', label: 'Gewichteter Score' },
{ value: 'all_must_pass', label: 'Alle müssen erfüllt sein' },
{ value: 'at_least_n', label: 'Mindestens N Regeln' },
]
export default function ProfileBuilder({ trainingType, onSave, onCancel, parameters }) {
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(false)
const [expandedSections, setExpandedSections] = useState({ minReq: true })
useEffect(() => {
// Initialize or load existing profile
if (trainingType.profile) {
setProfile(trainingType.profile)
} else {
// Create empty profile structure
setProfile({
version: '1.0',
name: `${trainingType.name_de} (Profil)`,
description: '',
rule_sets: {
minimum_requirements: {
enabled: true,
pass_strategy: 'weighted_score',
pass_threshold: 0.6,
rules: []
},
intensity_zones: {
enabled: false,
zones: []
},
training_effects: {
enabled: false,
default_effects: {
primary_abilities: [],
secondary_abilities: []
}
},
periodization: {
enabled: false,
frequency: {
per_week_optimal: 3,
per_week_max: 5
},
recovery: {
min_hours_between: 24
}
},
performance_indicators: {
enabled: false
},
safety: {
enabled: false,
warnings: []
}
}
})
}
}, [trainingType])
const toggleSection = (section) => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }))
}
const updateRuleSet = (key, updates) => {
setProfile(prev => ({
...prev,
rule_sets: {
...prev.rule_sets,
[key]: {
...prev.rule_sets[key],
...updates
}
}
}))
}
const addRule = () => {
const newRule = {
parameter: parameters[0]?.key || 'duration_min',
operator: 'gte',
value: 0,
weight: 3,
optional: false,
reason: ''
}
setProfile(prev => ({
...prev,
rule_sets: {
...prev.rule_sets,
minimum_requirements: {
...prev.rule_sets.minimum_requirements,
rules: [...prev.rule_sets.minimum_requirements.rules, newRule]
}
}
}))
}
const updateRule = (index, updates) => {
setProfile(prev => {
const rules = [...prev.rule_sets.minimum_requirements.rules]
rules[index] = { ...rules[index], ...updates }
return {
...prev,
rule_sets: {
...prev.rule_sets,
minimum_requirements: {
...prev.rule_sets.minimum_requirements,
rules
}
}
}
})
}
const removeRule = (index) => {
setProfile(prev => ({
...prev,
rule_sets: {
...prev.rule_sets,
minimum_requirements: {
...prev.rule_sets.minimum_requirements,
rules: prev.rule_sets.minimum_requirements.rules.filter((_, i) => i !== index)
}
}
}))
}
const handleSave = async () => {
setLoading(true)
try {
await onSave(profile)
} finally {
setLoading(false)
}
}
if (!profile) return <div className="spinner" />
const minReq = profile.rule_sets.minimum_requirements
return (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<h3 style={{ marginBottom: '8px' }}>
{trainingType.icon} {trainingType.name_de} - Profil konfigurieren
</h3>
<p style={{ fontSize: '13px', color: 'var(--text2)' }}>
Definiere Mindestanforderungen und Bewertungskriterien für diesen Trainingstyp
</p>
</div>
{/* Minimum Requirements */}
<div className="card" style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer'
}}
onClick={() => toggleSection('minReq')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<h4 style={{ margin: 0 }}>Mindestanforderungen</h4>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px' }}>
<input
type="checkbox"
checked={minReq.enabled}
onChange={(e) => {
e.stopPropagation()
updateRuleSet('minimum_requirements', { enabled: e.target.checked })
}}
/>
Aktiviert
</label>
</div>
{expandedSections.minReq ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</div>
{expandedSections.minReq && minReq.enabled && (
<div style={{ marginTop: '16px' }}>
{/* Strategy */}
<div style={{ marginBottom: '16px', paddingBottom: '16px', borderBottom: '1px solid var(--border)' }}>
<div className="form-row" style={{ marginBottom: '8px' }}>
<label className="form-label">Pass-Strategie</label>
<select
className="form-select"
value={minReq.pass_strategy}
onChange={(e) => updateRuleSet('minimum_requirements', { pass_strategy: e.target.value })}
>
{PASS_STRATEGIES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{minReq.pass_strategy === 'weighted_score' && (
<div className="form-row">
<label className="form-label">Threshold (Score 0-1)</label>
<input
type="number"
className="form-input"
step="0.1"
min="0"
max="1"
value={minReq.pass_threshold}
onChange={(e) => updateRuleSet('minimum_requirements', { pass_threshold: parseFloat(e.target.value) })}
/>
<span className="form-unit">{(minReq.pass_threshold * 100).toFixed(0)}%</span>
</div>
)}
</div>
{/* Rules */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontWeight: '600', fontSize: '14px', marginBottom: '12px' }}>
Regeln ({minReq.rules.length})
</div>
{minReq.rules.map((rule, idx) => {
const param = parameters.find(p => p.key === rule.parameter)
const availableOps = OPERATORS.filter(op =>
param ? op.types.includes(param.data_type) : true
)
return (
<div
key={idx}
style={{
background: 'var(--surface2)',
padding: '12px',
borderRadius: '8px',
marginBottom: '8px'
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.5fr 1fr 0.8fr 40px', gap: '8px', marginBottom: '8px' }}>
{/* Parameter */}
<select
className="form-select"
value={rule.parameter}
onChange={(e) => updateRule(idx, { parameter: e.target.value })}
style={{ fontSize: '13px' }}
>
{parameters.map(p => (
<option key={p.key} value={p.key}>
{p.name_de} ({p.unit || p.data_type})
</option>
))}
</select>
{/* Operator */}
<select
className="form-select"
value={rule.operator}
onChange={(e) => updateRule(idx, { operator: e.target.value })}
style={{ fontSize: '13px' }}
>
{availableOps.map(op => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
{/* Value */}
{rule.operator === 'between' ? (
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
className="form-input"
placeholder="Min"
value={Array.isArray(rule.value) ? rule.value[0] : 0}
onChange={(e) => updateRule(idx, {
value: [parseFloat(e.target.value), Array.isArray(rule.value) ? rule.value[1] : 0]
})}
style={{ fontSize: '13px' }}
/>
<input
type="number"
className="form-input"
placeholder="Max"
value={Array.isArray(rule.value) ? rule.value[1] : 0}
onChange={(e) => updateRule(idx, {
value: [Array.isArray(rule.value) ? rule.value[0] : 0, parseFloat(e.target.value)]
})}
style={{ fontSize: '13px' }}
/>
</div>
) : (
<input
type="number"
className="form-input"
value={rule.value}
onChange={(e) => updateRule(idx, { value: parseFloat(e.target.value) || 0 })}
style={{ fontSize: '13px' }}
/>
)}
{/* Weight */}
<input
type="number"
className="form-input"
placeholder="Gewicht"
min="1"
max="10"
value={rule.weight}
onChange={(e) => updateRule(idx, { weight: parseInt(e.target.value) || 1 })}
style={{ fontSize: '13px' }}
title="Gewichtung 1-10"
/>
{/* Delete */}
<button
className="btn"
onClick={() => removeRule(idx)}
style={{ padding: '6px', minWidth: 'auto' }}
>
<Trash2 size={14} />
</button>
</div>
{/* Reason */}
<input
type="text"
className="form-input"
placeholder="Begründung (optional)"
value={rule.reason}
onChange={(e) => updateRule(idx, { reason: e.target.value })}
style={{ fontSize: '12px' }}
/>
{/* Optional Checkbox */}
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', marginTop: '4px' }}>
<input
type="checkbox"
checked={rule.optional}
onChange={(e) => updateRule(idx, { optional: e.target.checked })}
/>
Optional (Regel wird übersprungen wenn Parameter fehlt)
</label>
</div>
)
})}
<button
className="btn btn-secondary btn-full"
onClick={addRule}
style={{ marginTop: '8px' }}
>
<Plus size={14} /> Regel hinzufügen
</button>
</div>
</div>
)}
</div>
{/* Other Rule Sets - Placeholder */}
<div className="card" style={{ marginBottom: '16px', padding: '12px', background: 'var(--surface2)' }}>
<div style={{ fontSize: '13px', color: 'var(--text2)' }}>
<strong>Weitere Dimensionen:</strong> Intensitätszonen, Training Effects, Periodization, Performance, Safety
<br />
<span style={{ fontSize: '12px' }}>
Aktuell nur JSON-Editor verfügbar. Visual Builder folgt in nächster Iteration.
</span>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px' }}>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
style={{ flex: 1 }}
>
💾 {loading ? 'Speichern...' : 'Profil speichern'}
</button>
<button
className="btn btn-secondary"
onClick={onCancel}
style={{ flex: 1 }}
>
Abbrechen
</button>
</div>
</div>
)
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react'
import { api } from '../utils/api'
import ProfileBuilder from '../components/ProfileBuilder'
/**
* AdminTrainingTypesPage - CRUD for training types
@ -16,6 +17,8 @@ export default function AdminTrainingTypesPage() {
const [formData, setFormData] = useState(null)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [editingProfileId, setEditingProfileId] = useState(null)
const [parameters, setParameters] = useState([])
useEffect(() => {
load()
@ -25,10 +28,12 @@ export default function AdminTrainingTypesPage() {
setLoading(true)
Promise.all([
api.adminListTrainingTypes(),
api.getTrainingCategories()
]).then(([typesData, catsData]) => {
api.getTrainingCategories(),
api.getTrainingParameters()
]).then(([typesData, catsData, paramsData]) => {
setTypes(typesData)
setCategories(catsData)
setParameters(paramsData.parameters || [])
setLoading(false)
}).catch(err => {
console.error('Failed to load training types:', err)
@ -109,6 +114,28 @@ export default function AdminTrainingTypesPage() {
}
}
const startEditProfile = (typeId) => {
setEditingProfileId(typeId)
setEditingId(null) // Close type editor if open
setFormData(null)
}
const cancelEditProfile = () => {
setEditingProfileId(null)
}
const handleSaveProfile = async (profile) => {
try {
await api.adminUpdateTrainingType(editingProfileId, { profile })
await load()
setEditingProfileId(null)
alert('✓ Profil gespeichert!')
} catch (err) {
alert('Speichern fehlgeschlagen: ' + err.message)
throw err
}
}
// Group by category
const grouped = {}
types.forEach(type => {
@ -162,6 +189,18 @@ export default function AdminTrainingTypesPage() {
</button>
)}
{/* Profile Builder */}
{editingProfileId && (
<div style={{ marginBottom: 16 }}>
<ProfileBuilder
trainingType={types.find(t => t.id === editingProfileId)}
parameters={parameters}
onSave={handleSaveProfile}
onCancel={cancelEditProfile}
/>
</div>
)}
{/* Edit form */}
{editingId && formData && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
@ -336,6 +375,18 @@ export default function AdminTrainingTypesPage() {
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{type.name_de} <span style={{ color: 'var(--text3)' }}>/ {type.name_en}</span>
{type.profile && (
<span style={{
marginLeft: 8,
padding: '2px 6px',
background: 'var(--accent)',
color: 'white',
borderRadius: 4,
fontSize: 10
}}>
Profil
</span>
)}
</div>
{type.subcategory && (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
@ -343,6 +394,19 @@ export default function AdminTrainingTypesPage() {
</div>
)}
</div>
<button
onClick={() => startEditProfile(type.id)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title="Profil konfigurieren"
>
<Settings size={16} />
</button>
<button
onClick={() => startEdit(type)}
style={{