feat: Implement reference value types reordering and confidence level sorting
- 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:
parent
45e4e64f15
commit
296e79c3b3
|
|
@ -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"})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 || '–'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user