2026-04-14 11:56:16 +02:00
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
|
import { Link } from 'react-router-dom'
|
2026-04-14 12:26:52 +02:00
|
|
|
|
import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react'
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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() {
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const [tab, setTab] = useState('catalog')
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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())
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const [editParam, setEditParam] = useState(null)
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
const [selCategory, setSelCategory] = useState('cardio')
|
|
|
|
|
|
const [catLinks, setCatLinks] = useState([])
|
|
|
|
|
|
const [catAdd, setCatAdd] = useState({
|
|
|
|
|
|
training_parameter_id: '',
|
|
|
|
|
|
sort_order: 0,
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
ui_group: '',
|
|
|
|
|
|
})
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const [editingCatId, setEditingCatId] = useState(null)
|
|
|
|
|
|
const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' })
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
const [selTypeId, setSelTypeId] = useState('')
|
|
|
|
|
|
const [typeLinks, setTypeLinks] = useState([])
|
|
|
|
|
|
const [typeAdd, setTypeAdd] = useState({
|
|
|
|
|
|
training_parameter_id: '',
|
|
|
|
|
|
sort_order: '',
|
|
|
|
|
|
required: '',
|
|
|
|
|
|
ui_group: '',
|
|
|
|
|
|
})
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const [editingTypeId, setEditingTypeId] = useState(null)
|
|
|
|
|
|
const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const categoryKeys =
|
|
|
|
|
|
Object.keys(catMeta).length > 0
|
|
|
|
|
|
? Object.keys(catMeta).sort()
|
|
|
|
|
|
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
|
|
|
|
|
|
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const saveEditedParameter = async () => {
|
|
|
|
|
|
if (!editParam) return
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.adminUpdateTrainingParameter(editParam.id, {
|
|
|
|
|
|
name_de: editParam.name_de.trim(),
|
|
|
|
|
|
name_en: editParam.name_en.trim(),
|
|
|
|
|
|
category: editParam.category,
|
|
|
|
|
|
data_type: editParam.data_type,
|
|
|
|
|
|
unit: editParam.unit?.trim() || null,
|
|
|
|
|
|
source_field: editParam.source_field?.trim() || null,
|
|
|
|
|
|
is_active: !!editParam.is_active,
|
|
|
|
|
|
validation_rules: editParam.validation_rules || {},
|
|
|
|
|
|
})
|
|
|
|
|
|
showToast('Parameter gespeichert')
|
|
|
|
|
|
setEditParam(null)
|
|
|
|
|
|
await refreshCatalog()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Update fehlgeschlagen')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const saveCatLink = async (linkId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.adminUpdateTrainingCategoryParameter(linkId, {
|
|
|
|
|
|
sort_order: Number(catDraft.sort_order) || 0,
|
|
|
|
|
|
required: !!catDraft.required,
|
|
|
|
|
|
ui_group: catDraft.ui_group.trim() || null,
|
|
|
|
|
|
})
|
|
|
|
|
|
showToast('Kategorie-Zuordnung aktualisiert')
|
|
|
|
|
|
setEditingCatId(null)
|
|
|
|
|
|
await loadCatLinks()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Update fehlgeschlagen')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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),
|
2026-04-14 12:26:52 +02:00
|
|
|
|
required:
|
|
|
|
|
|
typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
|
2026-04-14 11:56:16 +02:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
const saveTypeLink = async (linkId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {}
|
|
|
|
|
|
if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) {
|
|
|
|
|
|
payload.sort_order = Number(typeDraft.sort_order)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
payload.sort_order = null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeDraft.required === '' || typeDraft.required == null) {
|
|
|
|
|
|
payload.required = null
|
|
|
|
|
|
} else {
|
|
|
|
|
|
payload.required = typeDraft.required === true || typeDraft.required === 'true'
|
|
|
|
|
|
}
|
|
|
|
|
|
payload.ui_group = typeDraft.ui_group.trim() || null
|
|
|
|
|
|
await api.adminUpdateTrainingTypeParameter(linkId, payload)
|
|
|
|
|
|
showToast('Typ-Zuordnung aktualisiert')
|
|
|
|
|
|
setEditingTypeId(null)
|
|
|
|
|
|
await loadTypeLinks()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || 'Update fehlgeschlagen')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card section-gap"
|
|
|
|
|
|
style={{ background: 'var(--surface2)', border: '1px solid var(--border)', fontSize: 13, lineHeight: 1.55 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong>Hinweise</strong>
|
|
|
|
|
|
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>Daten:</strong> Offizielle Migrationen löschen keine Zeilen in <code>activity_log</code>. Leere
|
|
|
|
|
|
Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin – vor Prod immer{' '}
|
|
|
|
|
|
<code>pg_dump</code>.
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>Doppelzuordnung:</strong> Dieselbe Metrik darf <em>eine</em> Zeile pro Kategorie und <em>eine</em>{' '}
|
|
|
|
|
|
pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in <strong>Kategorie</strong> und{' '}
|
|
|
|
|
|
<strong>Trainingstyp</strong> vorkommt, überschreibt die <strong>Typ-Zeile</strong> sort/required/ui_group
|
|
|
|
|
|
(Merge in Layer 1) – kein Datenfehler.
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
|
|
|
|
|
|
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<div className="tabs" style={{ marginBottom: 16, overflowX: 'auto' }}>
|
|
|
|
|
|
{[
|
|
|
|
|
|
['catalog', 'Katalog'],
|
|
|
|
|
|
['category', 'Kategorie'],
|
|
|
|
|
|
['type', 'Trainingstyp'],
|
|
|
|
|
|
].map(([id, label]) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={'tab' + (tab === id ? ' active' : '')}
|
|
|
|
|
|
onClick={() => setTab(id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</button>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
|
2026-04-14 12:26:52 +02:00
|
|
|
|
{tab === 'catalog' && (
|
|
|
|
|
|
<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 }}>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<input
|
2026-04-14 12:26:52 +02:00
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={includeInactive}
|
|
|
|
|
|
onChange={(e) => setIncludeInactive(e.target.checked)}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
/>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
Inaktive
|
|
|
|
|
|
</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} /> Neu
|
|
|
|
|
|
</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 }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={paramForm.name_en}
|
|
|
|
|
|
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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"
|
|
|
|
|
|
value={paramForm.unit}
|
|
|
|
|
|
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
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>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{editParam && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
border: '1px solid var(--accent)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
marginBottom: 12,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="card-title" style={{ fontSize: 14 }}>
|
|
|
|
|
|
Bearbeiten: <code>{editParam.key}</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">name_de / name_en</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={editParam.name_de || ''}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={editParam.name_en || ''}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Gruppe / Typ</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={editParam.category}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{PARAM_GROUP.map((c) => (
|
|
|
|
|
|
<option key={c} value={c}>
|
|
|
|
|
|
{c}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={editParam.data_type}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, 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"
|
|
|
|
|
|
value={editParam.unit || ''}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={editParam.source_field || ''}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={!!editParam.is_active}
|
|
|
|
|
|
onChange={(e) => setEditParam((p) => ({ ...p, is_active: e.target.checked }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
aktiv
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" onClick={saveEditedParameter}>
|
|
|
|
|
|
<Save size={14} /> Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setEditParam(null)}>
|
|
|
|
|
|
Abbrechen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '4px 8px' }}
|
|
|
|
|
|
onClick={() => setEditParam({ ...r })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{r.is_active !== false && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-danger"
|
|
|
|
|
|
style={{ padding: '4px 8px' }}
|
|
|
|
|
|
onClick={() => deactivateParameter(r.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
))}
|
2026-04-14 12:26:52 +02:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{tab === 'category' && (
|
|
|
|
|
|
<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>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
2026-04-14 12:26:52 +02:00
|
|
|
|
value={catAdd.training_parameter_id}
|
|
|
|
|
|
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<option value="">— wählen —</option>
|
|
|
|
|
|
{activeParams.map((p) => (
|
|
|
|
|
|
<option key={p.id} value={p.id}>
|
|
|
|
|
|
{p.id} · {p.key} ({p.name_de})
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">sort</label>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<input
|
2026-04-14 12:26:52 +02:00
|
|
|
|
type="number"
|
2026-04-14 11:56:16 +02:00
|
|
|
|
className="form-input"
|
2026-04-14 12:26:52 +02:00
|
|
|
|
style={{ width: 80 }}
|
|
|
|
|
|
value={catAdd.sort_order}
|
|
|
|
|
|
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
/>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
</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>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
2026-04-14 12:26:52 +02:00
|
|
|
|
style={{ width: 120 }}
|
|
|
|
|
|
value={catAdd.ui_group}
|
|
|
|
|
|
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<button type="button" className="btn btn-primary" onClick={addCatLink}>
|
|
|
|
|
|
<Plus size={14} /> Hinzufügen
|
|
|
|
|
|
</button>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
|
|
|
|
|
{catLinks.map((l) => (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={l.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '10px 0',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{editingCatId === l.id ? (
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
|
|
|
|
|
<span style={{ flex: '1 1 200px' }}>
|
|
|
|
|
|
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">sort</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: 72 }}
|
|
|
|
|
|
value={catDraft.sort_order}
|
|
|
|
|
|
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={!!catDraft.required}
|
|
|
|
|
|
onChange={(e) => setCatDraft((d) => ({ ...d, required: e.target.checked }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
Pflicht
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: 100 }}
|
|
|
|
|
|
placeholder="ui_group"
|
|
|
|
|
|
value={catDraft.ui_group}
|
|
|
|
|
|
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" onClick={() => saveCatLink(l.id)}>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setEditingCatId(null)}>
|
|
|
|
|
|
Abbrechen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
|
|
|
|
|
|
{l.required ? ' · Pflicht' : ''}
|
|
|
|
|
|
{l.ui_group ? ` · ${l.ui_group}` : ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '4px 8px' }}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditingCatId(l.id)
|
|
|
|
|
|
setCatDraft({
|
|
|
|
|
|
sort_order: l.sort_order,
|
|
|
|
|
|
required: !!l.required,
|
|
|
|
|
|
ui_group: l.ui_group || '',
|
|
|
|
|
|
})
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil size={14} />
|
|
|
|
|
|
</button>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-danger"
|
|
|
|
|
|
style={{ padding: '4px 8px' }}
|
2026-04-14 12:26:52 +02:00
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
if (!confirm('Zuordnung entfernen?')) return
|
|
|
|
|
|
await api.adminDeleteTrainingCategoryParameter(l.id)
|
|
|
|
|
|
await loadCatLinks()
|
|
|
|
|
|
showToast('Entfernt')
|
|
|
|
|
|
}}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
|
</button>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
))}
|
2026-04-14 12:26:52 +02:00
|
|
|
|
</ul>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{tab === 'type' && (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Trainingstyp</label>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
2026-04-14 12:26:52 +02:00
|
|
|
|
value={selTypeId}
|
|
|
|
|
|
onChange={(e) => setSelTypeId(e.target.value)}
|
|
|
|
|
|
style={{ maxWidth: 420 }}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
{flatTypes.map((t) => (
|
|
|
|
|
|
<option key={t.id} value={t.id}>
|
|
|
|
|
|
{t.id} · {t.name_de} ({t.category})
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<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>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
|
|
|
|
|
|
{typeLinks.map((l) => (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={l.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '10px 0',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
fontSize: 13,
|
2026-04-14 11:56:16 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
{editingTypeId === l.id ? (
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
|
|
|
|
|
<span style={{ flex: '1 1 200px' }}>
|
|
|
|
|
|
<strong>{l.parameter_key}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">sort</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: 72 }}
|
|
|
|
|
|
value={typeDraft.sort_order}
|
|
|
|
|
|
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: 100 }}
|
|
|
|
|
|
value={typeDraft.required}
|
|
|
|
|
|
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Erben</option>
|
|
|
|
|
|
<option value="true">ja</option>
|
|
|
|
|
|
<option value="false">nein</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: 100 }}
|
|
|
|
|
|
placeholder="ui_group"
|
|
|
|
|
|
value={typeDraft.ui_group}
|
|
|
|
|
|
onChange={(e) => setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" onClick={() => saveTypeLink(l.id)}>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setEditingTypeId(null)}>
|
|
|
|
|
|
Abbrechen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
|
|
|
|
|
|
{l.required === null || l.required === undefined
|
|
|
|
|
|
? '—'
|
|
|
|
|
|
: l.required
|
|
|
|
|
|
? 'ja'
|
|
|
|
|
|
: 'nein'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '4px 8px' }}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditingTypeId(l.id)
|
|
|
|
|
|
setTypeDraft({
|
|
|
|
|
|
sort_order: l.sort_order ?? '',
|
|
|
|
|
|
required:
|
|
|
|
|
|
l.required === null || l.required === undefined
|
|
|
|
|
|
? ''
|
|
|
|
|
|
: l.required
|
|
|
|
|
|
? 'true'
|
|
|
|
|
|
: 'false',
|
|
|
|
|
|
ui_group: l.ui_group || '',
|
|
|
|
|
|
})
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
))}
|
2026-04-14 12:26:52 +02:00
|
|
|
|
</ul>
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
2026-04-14 12:26:52 +02:00
|
|
|
|
)}
|
2026-04-14 11:56:16 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|