feat: Implement Activity Attribute Profiles and session metrics editing
- Added new Admin UI for managing Activity Attribute Profiles. - Enhanced ActivityPage to support dynamic loading and editing of session metrics. - Updated API utility functions to handle new endpoints for training parameters and metrics. - Improved form handling for session metrics, including validation and error management. - Updated documentation to reflect new features and changes in session metrics handling.
This commit is contained in:
parent
48508c164e
commit
cf7379b2f6
|
|
@ -81,8 +81,8 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a
|
|||
|
||||
## 5. Agent-Checkliste (nächste Iterationen)
|
||||
|
||||
- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter.
|
||||
- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`.
|
||||
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
|
||||
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
|
||||
- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor).
|
||||
- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`.
|
||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ frontend/src/
|
|||
- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`).
|
||||
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
|
||||
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt).
|
||||
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
|
||||
|
||||
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
|
|||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||
import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage'
|
||||
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
|
|
@ -255,6 +256,7 @@ function AppShell() {
|
|||
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
|
||||
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="activity-attribute-profiles" element={<AdminActivityAttributeProfilesPage />} />
|
||||
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [
|
|||
label: 'Trainings-Profile',
|
||||
description: 'Training-Type-Profile (#15).',
|
||||
},
|
||||
{
|
||||
to: '/admin/activity-attribute-profiles',
|
||||
label: 'Session-Metriken (EAV)',
|
||||
description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,80 @@ function empty() {
|
|||
}
|
||||
}
|
||||
|
||||
function buildMetricsPayload(schema, draft) {
|
||||
const out = []
|
||||
for (const s of schema) {
|
||||
const raw = draft[s.key]
|
||||
if (s.data_type === 'boolean') {
|
||||
if (raw === '' || raw === null || raw === undefined) {
|
||||
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
|
||||
continue
|
||||
}
|
||||
out.push({ parameter_key: s.key, value: !!raw })
|
||||
continue
|
||||
}
|
||||
if (raw === '' || raw === null || raw === undefined) {
|
||||
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
|
||||
continue
|
||||
}
|
||||
let v = raw
|
||||
if (s.data_type === 'integer') {
|
||||
v = parseInt(String(raw), 10)
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else if (s.data_type === 'float') {
|
||||
v = parseFloat(String(raw))
|
||||
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
|
||||
} else {
|
||||
v = String(raw)
|
||||
}
|
||||
out.push({ parameter_key: s.key, value: v })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function SessionMetricsFields({ schema, values, setValues }) {
|
||||
if (!schema || schema.length === 0) return null
|
||||
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
|
||||
return (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||||
{schema.map((s) => (
|
||||
<div key={s.key} className="form-row">
|
||||
<label className="form-label">
|
||||
{s.name_de}
|
||||
{s.required ? ' *' : ''}
|
||||
{s.unit ? ` (${s.unit})` : ''}
|
||||
</label>
|
||||
{s.data_type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', marginRight: 'auto' }}
|
||||
checked={!!values[s.key]}
|
||||
onChange={(e) => set(s.key, e.target.checked)}
|
||||
/>
|
||||
) : s.data_type === 'integer' || s.data_type === 'float' ? (
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
step={s.data_type === 'integer' ? 1 : 'any'}
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||
function ImportPanel({ onImported }) {
|
||||
const fileRef = useRef()
|
||||
|
|
@ -85,7 +159,17 @@ function ImportPanel({ onImported }) {
|
|||
}
|
||||
|
||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||
function EntryForm({
|
||||
form,
|
||||
setForm,
|
||||
onSave,
|
||||
onCancel,
|
||||
saveLabel = 'Speichern',
|
||||
saving = false,
|
||||
error = null,
|
||||
usage = null,
|
||||
formExtras = null,
|
||||
}) {
|
||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -144,6 +228,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
|||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
{formExtras}
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
|
||||
{error}
|
||||
|
|
@ -181,6 +266,10 @@ export default function ActivityPage() {
|
|||
const [error, setError] = useState(null)
|
||||
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||
const [categories, setCategories] = useState({}) // v9d: Training categories
|
||||
const [sessionDetail, setSessionDetail] = useState(null)
|
||||
const [metricDraft, setMetricDraft] = useState({})
|
||||
const [sessionLoadError, setSessionLoadError] = useState(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||
|
|
@ -200,6 +289,46 @@ export default function ActivityPage() {
|
|||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||
},[])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing?.id) {
|
||||
setSessionDetail(null)
|
||||
setMetricDraft({})
|
||||
setSessionLoadError(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setSessionLoadError(null)
|
||||
;(async () => {
|
||||
try {
|
||||
const d = await api.getActivitySession(editing.id)
|
||||
if (!cancelled) setSessionDetail(d)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setSessionDetail(null)
|
||||
setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [editing?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionDetail) {
|
||||
setMetricDraft({})
|
||||
return
|
||||
}
|
||||
const m = {}
|
||||
for (const row of sessionDetail.metrics || []) {
|
||||
m[row.key] = row.value
|
||||
}
|
||||
for (const s of sessionDetail.schema || []) {
|
||||
if (!(s.key in m)) {
|
||||
m[s.key] = s.data_type === 'boolean' ? false : ''
|
||||
}
|
||||
}
|
||||
setMetricDraft(m)
|
||||
}, [sessionDetail])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
|
@ -226,9 +355,30 @@ export default function ActivityPage() {
|
|||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const payload = {...editing}
|
||||
await api.updateActivity(editing.id, payload)
|
||||
setEditing(null); await load()
|
||||
setSavingEdit(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = { ...editing }
|
||||
delete payload.id
|
||||
if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10)
|
||||
await api.updateActivity(editing.id, payload)
|
||||
if (sessionDetail?.schema?.length > 0) {
|
||||
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
|
||||
await api.putActivityMetrics(editing.id, { metrics })
|
||||
}
|
||||
setEditing(null)
|
||||
setSessionDetail(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Speichern fehlgeschlagen')
|
||||
setTimeout(() => setError(null), 6000)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
|
|
@ -347,8 +497,27 @@ export default function ActivityPage() {
|
|||
return (
|
||||
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
|
||||
{isEd ? (
|
||||
<EntryForm form={editing} setForm={setEditing}
|
||||
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
|
||||
<EntryForm
|
||||
form={editing}
|
||||
setForm={setEditing}
|
||||
onSave={handleUpdate}
|
||||
onCancel={() => { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }}
|
||||
saveLabel="Speichern"
|
||||
saving={savingEdit}
|
||||
error={error}
|
||||
formExtras={
|
||||
<>
|
||||
{sessionLoadError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>{sessionLoadError}</div>
|
||||
)}
|
||||
<SessionMetricsFields
|
||||
schema={sessionDetail?.schema}
|
||||
values={metricDraft}
|
||||
setValues={setMetricDraft}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
||||
|
|
|
|||
594
frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
Normal file
594
frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Trash2, Save, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
|
||||
const DATA_TYPES = ['integer', 'float', 'string', 'boolean']
|
||||
|
||||
const emptyParamForm = () => ({
|
||||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
category: 'physical',
|
||||
data_type: 'float',
|
||||
unit: '',
|
||||
source_field: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
export default function AdminActivityAttributeProfilesPage() {
|
||||
const [params, setParams] = useState([])
|
||||
const [includeInactive, setIncludeInactive] = useState(false)
|
||||
const [catMeta, setCatMeta] = useState({})
|
||||
const [flatTypes, setFlatTypes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const [showParamForm, setShowParamForm] = useState(false)
|
||||
const [paramForm, setParamForm] = useState(emptyParamForm())
|
||||
|
||||
const [selCategory, setSelCategory] = useState('cardio')
|
||||
const [catLinks, setCatLinks] = useState([])
|
||||
const [catAdd, setCatAdd] = useState({
|
||||
training_parameter_id: '',
|
||||
sort_order: 0,
|
||||
required: false,
|
||||
ui_group: '',
|
||||
})
|
||||
|
||||
const [selTypeId, setSelTypeId] = useState('')
|
||||
const [typeLinks, setTypeLinks] = useState([])
|
||||
const [typeAdd, setTypeAdd] = useState({
|
||||
training_parameter_id: '',
|
||||
sort_order: '',
|
||||
required: '',
|
||||
ui_group: '',
|
||||
})
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2800)
|
||||
}
|
||||
|
||||
const refreshCatalog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [p, cats, flat] = await Promise.all([
|
||||
api.adminListTrainingParameters(includeInactive),
|
||||
api.getTrainingCategories(),
|
||||
api.listTrainingTypesFlat(),
|
||||
])
|
||||
setParams(Array.isArray(p) ? p : [])
|
||||
setCatMeta(cats && typeof cats === 'object' ? cats : {})
|
||||
setFlatTypes(Array.isArray(flat) ? flat : [])
|
||||
setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : ''))
|
||||
} catch (e) {
|
||||
setError(e.message || 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [includeInactive])
|
||||
|
||||
useEffect(() => {
|
||||
refreshCatalog()
|
||||
}, [refreshCatalog])
|
||||
|
||||
const loadCatLinks = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.adminListTrainingCategoryParameters(selCategory)
|
||||
setCatLinks(Array.isArray(data) ? data : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Kategorie-Zuordnungen')
|
||||
}
|
||||
}, [selCategory])
|
||||
|
||||
useEffect(() => {
|
||||
loadCatLinks()
|
||||
}, [loadCatLinks])
|
||||
|
||||
const loadTypeLinks = useCallback(async () => {
|
||||
if (!selTypeId) {
|
||||
setTypeLinks([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.adminListTrainingTypeParameters(Number(selTypeId))
|
||||
setTypeLinks(Array.isArray(data) ? data : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Typ-Zuordnungen')
|
||||
}
|
||||
}, [selTypeId])
|
||||
|
||||
useEffect(() => {
|
||||
loadTypeLinks()
|
||||
}, [loadTypeLinks])
|
||||
|
||||
const activeParams = params.filter((p) => p.is_active !== false)
|
||||
|
||||
const saveNewParameter = async () => {
|
||||
setError(null)
|
||||
if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
|
||||
setError('key, name_de und name_en sind Pflicht.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.adminCreateTrainingParameter({
|
||||
key: paramForm.key.trim().toLowerCase(),
|
||||
name_de: paramForm.name_de.trim(),
|
||||
name_en: paramForm.name_en.trim(),
|
||||
category: paramForm.category,
|
||||
data_type: paramForm.data_type,
|
||||
unit: paramForm.unit.trim() || null,
|
||||
source_field: paramForm.source_field.trim() || null,
|
||||
is_active: paramForm.is_active,
|
||||
validation_rules: {},
|
||||
})
|
||||
showToast('Parameter angelegt')
|
||||
setShowParamForm(false)
|
||||
setParamForm(emptyParamForm())
|
||||
await refreshCatalog()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const deactivateParameter = async (id) => {
|
||||
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
|
||||
try {
|
||||
await api.adminDeleteTrainingParameter(id)
|
||||
showToast('Deaktiviert')
|
||||
await refreshCatalog()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const addCatLink = async () => {
|
||||
const pid = parseInt(catAdd.training_parameter_id, 10)
|
||||
if (!pid) {
|
||||
setError('Parameter-ID wählen')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.adminAddTrainingCategoryParameter({
|
||||
training_category: selCategory,
|
||||
training_parameter_id: pid,
|
||||
sort_order: Number(catAdd.sort_order) || 0,
|
||||
required: !!catAdd.required,
|
||||
ui_group: catAdd.ui_group.trim() || null,
|
||||
})
|
||||
showToast('Zuordnung gespeichert')
|
||||
setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' })
|
||||
await loadCatLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Konflikt oder ungültige Daten')
|
||||
}
|
||||
}
|
||||
|
||||
const addTypeLink = async () => {
|
||||
const tid = Number(selTypeId)
|
||||
const pid = parseInt(typeAdd.training_parameter_id, 10)
|
||||
if (!tid || !pid) {
|
||||
setError('Trainingstyp und Parameter wählen')
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
training_type_id: tid,
|
||||
training_parameter_id: pid,
|
||||
sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
|
||||
required: typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
|
||||
ui_group: typeAdd.ui_group.trim() || null,
|
||||
}
|
||||
try {
|
||||
await api.adminAddTrainingTypeParameter(body)
|
||||
showToast('Typ-Zuordnung gespeichert')
|
||||
setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' })
|
||||
await loadTypeLinks()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Konflikt oder ungültige Daten')
|
||||
}
|
||||
}
|
||||
|
||||
const categoryKeys =
|
||||
Object.keys(catMeta).length > 0
|
||||
? Object.keys(catMeta).sort()
|
||||
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
|
||||
|
||||
if (loading && !params.length) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="spinner" /> Lade…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="capture-page">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
|
||||
← Training (Hub)
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="page-title">Session-Metriken (EAV)</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', maxWidth: 720, lineHeight: 1.5 }}>
|
||||
Messgrößen-Katalog und Zuordnung zu <strong>Kategorie</strong> (Basis) bzw.{' '}
|
||||
<strong>Trainingstyp</strong> (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer
|
||||
Aktivität, wenn der Eintrag passend kategorisiert ist.
|
||||
</p>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ background: 'var(--accent-light)', color: 'var(--accent-dark)', marginBottom: 12 }}
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
className="card"
|
||||
style={{ background: '#FCEBEB', color: '#D85A30', marginBottom: 12, fontSize: 14 }}
|
||||
>
|
||||
{error}
|
||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setError(null)}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>Parameter-Katalog</span>
|
||||
<label style={{ fontSize: 12, fontWeight: 400, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeInactive}
|
||||
onChange={(e) => setIncludeInactive(e.target.checked)}
|
||||
/>
|
||||
Inaktive anzeigen
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
|
||||
<RefreshCw size={14} /> Neu laden
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
|
||||
<Plus size={14} /> Neuer Parameter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showParamForm && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label className="form-label">key</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="z. B. avg_power"
|
||||
value={paramForm.key}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">name_de / name_en</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_de}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||
placeholder="DE"
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_en}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||
placeholder="EN"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe / Datentyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.category}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.data_type}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit / source_field</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="z. B. W"
|
||||
value={paramForm.unit}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="activity_log-Spalte (optional)"
|
||||
value={paramForm.source_field}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
|
||||
<Save size={14} /> Anlegen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowParamForm(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>key</th>
|
||||
<th>DE</th>
|
||||
<th>Typ</th>
|
||||
<th>aktiv</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{params.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
<code>{r.key}</code>
|
||||
</td>
|
||||
<td>{r.name_de}</td>
|
||||
<td>
|
||||
{r.data_type} · {r.category}
|
||||
</td>
|
||||
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
|
||||
<td>
|
||||
{r.is_active !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={() => deactivateParameter(r.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kategorie</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selCategory}
|
||||
onChange={(e) => setSelCategory(e.target.value)}
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
{categoryKeys.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{catMeta[k]?.name_de || k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={catAdd.training_parameter_id}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{activeParams.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id} · {p.key} ({p.name_de})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={catAdd.sort_order}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={catAdd.required}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, required: e.target.checked }))}
|
||||
/>
|
||||
Pflicht
|
||||
</label>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
value={catAdd.ui_group}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={addCatLink}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
||||
{catLinks.map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
|
||||
{l.required ? ' · Pflicht' : ''}
|
||||
{l.ui_group ? ` · ${l.ui_group}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Zuordnung entfernen?')) return
|
||||
await api.adminDeleteTrainingCategoryParameter(l.id)
|
||||
await loadCatLinks()
|
||||
showToast('Entfernt')
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingstyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selTypeId}
|
||||
onChange={(e) => setSelTypeId(e.target.value)}
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
{flatTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.id} · {t.name_de} ({t.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={typeAdd.training_parameter_id}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{activeParams.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id} · {p.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort (leer=Erben)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={typeAdd.sort_order}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Pflicht (leer=Erben)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
value={typeAdd.required}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="true">ja</option>
|
||||
<option value="false">nein</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
value={typeAdd.ui_group}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={addTypeLink}>
|
||||
<Plus size={14} /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
||||
{typeLinks.map((l) => (
|
||||
<li
|
||||
key={l.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
|
||||
{l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Zuordnung entfernen?')) return
|
||||
await api.adminDeleteTrainingTypeParameter(l.id)
|
||||
await loadTypeLinks()
|
||||
showToast('Entfernt')
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -314,6 +314,30 @@ export const api = {
|
|||
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
|
||||
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
|
||||
|
||||
// Admin: Training session metrics (EAV) & attribute profiles (Migration 054)
|
||||
adminListTrainingParameters: (includeInactive = false) =>
|
||||
req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`),
|
||||
adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)),
|
||||
adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)),
|
||||
adminDeleteTrainingParameter: (id) =>
|
||||
req(`/admin/training-parameters/${id}`, { method: 'DELETE' }),
|
||||
adminListTrainingCategoryParameters: (category = '') =>
|
||||
req(
|
||||
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
|
||||
),
|
||||
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
|
||||
adminDeleteTrainingCategoryParameter: (id) =>
|
||||
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
|
||||
adminListTrainingTypeParameters: (trainingTypeId) =>
|
||||
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
|
||||
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
|
||||
adminDeleteTrainingTypeParameter: (id) =>
|
||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
|
||||
putActivityMetrics: (id, body) =>
|
||||
req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)),
|
||||
|
||||
// Sleep Module (v9d Phase 2b)
|
||||
listSleep: (l=90) => req(`/sleep?limit=${l}`),
|
||||
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user