feat: Implement reference value types reordering and confidence level sorting
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- Added a new API endpoint for reordering reference value types based on user-defined order.
- Updated the AdminReferenceValueTypesPage to allow users to reorder types using up/down buttons.
- Introduced a consistent confidence level sorting mechanism across the application.
- Refactored related components to remove unused sort order fields and improve user experience.
This commit is contained in:
Lars 2026-04-06 21:40:55 +02:00
parent 45e4e64f15
commit 296e79c3b3
7 changed files with 149 additions and 31 deletions

View File

@ -37,6 +37,9 @@ REF_VALUE_METHODS = frozenset(
REF_VALUE_CONFIDENCE = frozenset({"high", "medium", "low", "unknown"})
# Anzeigereihenfolge (nicht alphabetisch)
REF_VALUE_CONFIDENCE_ORDER = ("high", "medium", "low", "unknown")
VALUE_DATA_TYPES = frozenset({"integer", "decimal", "percentage", "text", "enum"})

View File

@ -48,7 +48,6 @@ class ReferenceValueTypeAdminCreate(BaseModel):
default_unit: Optional[str] = Field(None, max_length=32)
value_data_type: str = "decimal"
validation_rules: Optional[dict] = None
sort_order: int = 0
active: bool = True
metadata: Optional[dict] = None
@ -60,11 +59,14 @@ class ReferenceValueTypeAdminUpdate(BaseModel):
default_unit: Optional[str] = Field(None, max_length=32)
value_data_type: Optional[str] = None
validation_rules: Optional[dict] = None
sort_order: Optional[int] = None
active: Optional[bool] = None
metadata: Optional[dict] = None
class ReferenceValueTypesReorderBody(BaseModel):
ordered_ids: list[int] = Field(..., min_length=1)
def _normalize_key(key: str) -> str:
k = key.strip().lower()
if not KEY_PATTERN.match(k):
@ -106,6 +108,37 @@ def admin_list_reference_value_types(session: dict = Depends(require_admin)):
return [_serialize_type(r2d(r)) for r in cur.fetchall()]
@router.post("/reorder")
def admin_reorder_reference_value_types(
body: ReferenceValueTypesReorderBody,
session: dict = Depends(require_admin),
):
"""Globale Reihenfolge setzen (sort_order = 10, 20, …). Liste muss alle Typ-IDs genau einmal enthalten."""
ids = body.ordered_ids
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM reference_value_types")
all_ids = sorted([r["id"] for r in cur.fetchall()])
if len(ids) != len(all_ids):
raise HTTPException(
400,
f"Erwartet {len(all_ids)} Einträge, erhalten {len(ids)}.",
)
if sorted(ids) != all_ids:
raise HTTPException(400, "Die ID-Liste muss alle Kennwert-Typen exakt einmal enthalten (keine Duplikate).")
if len(set(ids)) != len(ids):
raise HTTPException(400, "Doppelte IDs sind nicht erlaubt.")
with get_db() as conn:
cur = get_cursor(conn)
for idx, tid in enumerate(ids):
cur.execute(
"UPDATE reference_value_types SET sort_order = %s WHERE id = %s",
((idx + 1) * 10, tid),
)
return {"ok": True}
@router.get("/{type_id}")
def admin_get_reference_value_type(type_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
@ -148,6 +181,8 @@ def admin_create_reference_value_type(
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute("SELECT COALESCE(MAX(sort_order), 0) AS m FROM reference_value_types")
next_sort = int(cur.fetchone()["m"]) + 10
cur.execute(
"""
INSERT INTO reference_value_types
@ -166,7 +201,7 @@ def admin_create_reference_value_type(
du,
vdt,
Json(rules),
body.sort_order,
next_sort,
body.active,
Json(meta),
),

View File

@ -18,6 +18,7 @@ from auth import require_auth
from db import get_db, get_cursor, r2d
from reference_value_validation import (
REF_VALUE_CONFIDENCE,
REF_VALUE_CONFIDENCE_ORDER,
REF_VALUE_METHODS,
REF_VALUE_SOURCES,
validate_meta_confidence,
@ -112,7 +113,7 @@ def list_reference_value_meta_enums(session: dict = Depends(require_auth)):
return {
"sources": sorted(REF_VALUE_SOURCES),
"methods": sorted(REF_VALUE_METHODS),
"confidence_levels": sorted(REF_VALUE_CONFIDENCE),
"confidence_levels": [x for x in REF_VALUE_CONFIDENCE_ORDER if x in REF_VALUE_CONFIDENCE],
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Gauge, Plus, Pencil, Trash2, Save, X } from 'lucide-react'
import { Gauge, Plus, Pencil, Trash2, Save, X, ChevronUp, ChevronDown } from 'lucide-react'
import { api } from '../utils/api'
import { VALUE_DATA_TYPE_LABELS } from '../utils/referenceValueMeta'
@ -47,7 +47,6 @@ const emptyForm = () => ({
vr_max_length: '',
vr_not_empty: true,
vr_enum_list: '',
sort_order: 0,
active: true,
metadata_json: '{}',
})
@ -55,6 +54,7 @@ const emptyForm = () => ({
export default function AdminReferenceValueTypesPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [reorderBusy, setReorderBusy] = useState(false)
const [error, setError] = useState(null)
const [toast, setToast] = useState(null)
const [editingId, setEditingId] = useState(null)
@ -106,7 +106,6 @@ export default function AdminReferenceValueTypesPage() {
vr_max_length: vr.max_length != null ? String(vr.max_length) : '',
vr_not_empty: vr.not_empty !== false,
vr_enum_list: allowed,
sort_order: r.sort_order ?? 0,
active: !!r.active,
metadata_json: r.metadata && typeof r.metadata === 'object' ? JSON.stringify(r.metadata, null, 2) : '{}',
})
@ -155,7 +154,6 @@ export default function AdminReferenceValueTypesPage() {
default_unit: form.default_unit.trim(),
value_data_type: form.value_data_type,
validation_rules,
sort_order: Number(form.sort_order) || 0,
active: !!form.active,
metadata,
}
@ -201,6 +199,24 @@ export default function AdminReferenceValueTypesPage() {
}
}
const moveRow = async (index, dir) => {
const j = dir === 'up' ? index - 1 : index + 1
if (j < 0 || j >= rows.length) return
setReorderBusy(true)
setError(null)
const next = [...rows]
;[next[index], next[j]] = [next[j], next[index]]
try {
await api.adminReorderReferenceValueTypes(next.map((r) => r.id))
setRows(next)
} catch (e) {
setError(e.message || 'Reihenfolge konnte nicht gespeichert werden')
await load()
} finally {
setReorderBusy(false)
}
}
const plausibilisierungBlock = () => {
const t = form.value_data_type
if (t === 'integer' || t === 'decimal' || t === 'percentage') {
@ -450,18 +466,6 @@ export default function AdminReferenceValueTypesPage() {
placeholder="bpm, %, Stufe, …"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-admin-sort">
Sortierung
</label>
<input
id="ref-admin-sort"
type="number"
className="form-input"
value={form.sort_order}
onChange={(e) => setForm((f) => ({ ...f, sort_order: e.target.value }))}
/>
</div>
<div className="settings-page__field">
<span className="settings-page__field-label">Sichtbarkeit</span>
<label
@ -515,23 +519,52 @@ export default function AdminReferenceValueTypesPage() {
<div className="card">
<div className="card-title">Alle Typen ({rows.length})</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 12, lineHeight: 1.5 }}>
Reihenfolge in der Liste und in den Nutzer-Dropdowns: Zeile mit <strong>hoch/runter</strong> verschieben.
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
<th style={{ padding: '8px 4px', width: 44 }} aria-label="Reihenfolge" title="Reihenfolge" />
<th style={{ padding: '8px 6px' }}>Key</th>
<th style={{ padding: '8px 6px' }}>Name</th>
<th style={{ padding: '8px 6px' }}>Kategorie</th>
<th style={{ padding: '8px 6px' }}>Typ</th>
<th style={{ padding: '8px 6px' }}>Einheit</th>
<th style={{ padding: '8px 6px' }}>Sort.</th>
<th style={{ padding: '8px 6px' }}>Aktiv</th>
<th style={{ padding: '8px 6px' }} />
</tr>
</thead>
<tbody>
{rows.map((r) => (
{rows.map((r, i) => (
<tr key={r.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '6px 4px', verticalAlign: 'middle' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 6px', minHeight: 0, lineHeight: 1 }}
disabled={reorderBusy || i === 0}
title="Nach oben"
onClick={() => moveRow(i, 'up')}
aria-label="Nach oben"
>
<ChevronUp size={16} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 6px', minHeight: 0, lineHeight: 1 }}
disabled={reorderBusy || i === rows.length - 1}
title="Nach unten"
onClick={() => moveRow(i, 'down')}
aria-label="Nach unten"
>
<ChevronDown size={16} />
</button>
</div>
</td>
<td style={{ padding: '10px 6px', fontFamily: 'monospace', fontSize: 13 }}>{r.key}</td>
<td style={{ padding: '10px 6px' }}>{r.label}</td>
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.category || ''}</td>
@ -539,7 +572,6 @@ export default function AdminReferenceValueTypesPage() {
{VALUE_DATA_TYPE_LABELS[r.value_data_type] || r.value_data_type || ''}
</td>
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.default_unit || ''}</td>
<td style={{ padding: '10px 6px' }}>{r.sort_order}</td>
<td style={{ padding: '10px 6px' }}>{r.active ? '✓' : ''}</td>
<td style={{ padding: '6px', textAlign: 'right', whiteSpace: 'nowrap' }}>
<button

View File

@ -7,6 +7,7 @@ import {
labelMethod,
labelConfidence,
VALUE_DATA_TYPE_LABELS,
sortConfidenceKeys,
} from '../utils/referenceValueMeta'
const DEFAULT_FORM_META = {
@ -462,7 +463,7 @@ export default function ProfileReferenceValuesPage() {
value={form.confidence}
onChange={(e) => setForm((f) => ({ ...f, confidence: e.target.value }))}
>
{(metaEnums.confidence_levels || []).map((k) => (
{sortConfidenceKeys(metaEnums.confidence_levels || []).map((k) => (
<option key={k} value={k}>
{labelConfidence(k)}
</option>

View File

@ -5,6 +5,36 @@ export function setProfileId(id) { _profileId = id }
const BASE = '/api'
/**
* FastAPI-Fehler: `detail` kann String, Objekt oder Validierungs-Array sein.
*/
export function formatFastApiDetail(detail, fallback = '') {
if (detail == null || detail === '') {
return fallback || 'Anfrage fehlgeschlagen'
}
if (typeof detail === 'string') {
return detail
}
if (Array.isArray(detail)) {
const parts = detail.map((e) => {
if (typeof e === 'string') return e
if (e && typeof e === 'object') {
const loc = Array.isArray(e.loc) ? e.loc.filter((x) => x != null && x !== '').join('.') : ''
const msg = e.msg || e.message || ''
if (loc && msg) return `${loc}: ${msg}`
return msg || loc || ''
}
return String(e)
}).filter(Boolean)
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
}
if (typeof detail === 'object') {
if (typeof detail.msg === 'string') return detail.msg
if (typeof detail.message === 'string') return detail.message
}
return fallback || 'Anfrage fehlgeschlagen'
}
function hdrs(extra={}) {
const h = {...extra}
if (_profileId) h['X-Profile-Id'] = _profileId
@ -16,14 +46,14 @@ function hdrs(extra={}) {
async function req(path, opts={}) {
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
if (!res.ok) {
const err = await res.text()
// Try to parse JSON error with detail field
const errText = await res.text()
let parsed = null
try {
const parsed = JSON.parse(err)
throw new Error(parsed.detail || err)
parsed = JSON.parse(errText)
} catch {
throw new Error(err)
throw new Error(errText.trim() || `HTTP ${res.status}`)
}
throw new Error(formatFastApiDetail(parsed.detail, errText.trim() || `HTTP ${res.status}`))
}
return res.json()
}
@ -54,6 +84,12 @@ export const api = {
adminCreateReferenceValueType: (d) => req('/admin/reference-value-types', json(d)),
adminUpdateReferenceValueType: (id, d) => req(`/admin/reference-value-types/${id}`, jput(d)),
adminDeleteReferenceValueType: (id) => req(`/admin/reference-value-types/${id}`, { method: 'DELETE' }),
adminReorderReferenceValueTypes: (orderedIds) =>
req('/admin/reference-value-types/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ordered_ids: orderedIds }),
}),
// Weight
listWeight: (l=365) => req(`/weight?limit=${l}`),
@ -85,7 +121,7 @@ export const api = {
importActivityCsv: async(file)=>{
const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
},
// Photos
@ -103,7 +139,7 @@ export const api = {
importCsv: async(file)=>{
const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()})
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
},
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
nutritionCorrelations: () => req('/nutrition/correlations'),

View File

@ -30,6 +30,16 @@ export const REF_CONFIDENCE_LABELS = {
unknown: 'Unbekannt',
}
/** Reihenfolge für Dropdowns (nicht alphabetisch) */
export const REF_CONFIDENCE_ORDER = ['high', 'medium', 'low', 'unknown']
export function sortConfidenceKeys(keys) {
if (!Array.isArray(keys)) return []
const known = REF_CONFIDENCE_ORDER.filter((k) => keys.includes(k))
const rest = [...keys].filter((k) => !REF_CONFIDENCE_ORDER.includes(k)).sort()
return [...known, ...rest]
}
export function labelSource(k) {
return REF_SOURCE_LABELS[k] || k || ''
}