feat: Visual Profile Builder integrated into Training Types page (#15)
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:
parent
2abaac22cf
commit
6fa15f7f57
403
frontend/src/components/ProfileBuilder.jsx
Normal file
403
frontend/src/components/ProfileBuilder.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user