feat: Enhance reference value types management with validation rules and metadata
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

- Updated the backend to include new fields for validation rules and metadata in reference value types.
- Enhanced the AdminReferenceValueTypesPage to support new validation rules for different data types.
- Improved the ProfileReferenceValuesPage to handle validation and metadata for profile reference values.
- Added API endpoint for fetching reference value metadata enums to support frontend validation.
- Refactored frontend forms to incorporate new fields and validation logic for a better user experience.
This commit is contained in:
Lars 2026-04-06 21:25:42 +02:00
parent f04318f76a
commit 45e4e64f15
9 changed files with 864 additions and 137 deletions

View File

@ -0,0 +1,54 @@
-- Migration 038: Referenzwert-Typen — Kategorie, Datentyp, Plausibilisierung; confidence als Diskretwert
-- Date: 2026-04-06
ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS category TEXT;
ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS value_data_type VARCHAR(32) NOT NULL DEFAULT 'decimal';
ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS validation_rules JSONB NOT NULL DEFAULT '{}';
ALTER TABLE reference_value_types DROP CONSTRAINT IF EXISTS rvt_value_data_type_chk;
ALTER TABLE reference_value_types ADD CONSTRAINT rvt_value_data_type_chk CHECK (
value_data_type IN ('integer', 'decimal', 'percentage', 'text', 'enum')
);
COMMENT ON COLUMN reference_value_types.category IS 'Freitext-Kategorie (UI/Admin)';
COMMENT ON COLUMN reference_value_types.value_data_type IS 'Logischer Wert-Typ für Erfassung & Validierung';
COMMENT ON COLUMN reference_value_types.validation_rules IS 'JSON: min, max, positive_only, max_length, not_empty, allowed_values[]';
-- Bestehende Seeds mit sinnvollen Defaults
UPDATE reference_value_types SET
value_data_type = 'integer',
validation_rules = '{"min": 40, "max": 240, "positive_only": true}'::jsonb
WHERE key IN ('max_heart_rate', 'resting_heart_rate');
UPDATE reference_value_types SET
value_data_type = 'integer',
validation_rules = '{"min": 80, "max": 210, "positive_only": true}'::jsonb
WHERE key IN ('anaerobic_threshold_hr', 'aerobic_threshold_hr');
UPDATE reference_value_types SET
value_data_type = 'integer',
validation_rules = '{"min": 0, "max": 21, "positive_only": false}'::jsonb
WHERE key = 'training_frequency_weekly';
UPDATE reference_value_types SET
value_data_type = 'text',
validation_rules = '{"max_length": 200, "not_empty": true}'::jsonb
WHERE key = 'fitness_level';
-- profile_reference_values.confidence: von NUMERIC auf diskrete Stufen
ALTER TABLE profile_reference_values ALTER COLUMN confidence DROP DEFAULT;
ALTER TABLE profile_reference_values
ALTER COLUMN confidence TYPE VARCHAR(32)
USING (NULL::varchar(32));
COMMENT ON COLUMN profile_reference_values.confidence IS 'high | medium | low | unknown';
-- Optionaler leerer Text (not_empty=false): leere Zeichenkette statt „kein Wert“
ALTER TABLE profile_reference_values DROP CONSTRAINT IF EXISTS profile_reference_values_value_ck;
ALTER TABLE profile_reference_values ADD CONSTRAINT profile_reference_values_value_ck CHECK (
value_numeric IS NOT NULL OR value_text IS NOT NULL
);

View File

@ -0,0 +1,167 @@
"""
Validierung & Konstanten für persönliche Referenzwerte.
"""
from __future__ import annotations
from typing import Any, Optional
from fastapi import HTTPException
REF_VALUE_SOURCES = frozenset(
{
"manual_user",
"manual_admin",
"import_device",
"import_app",
"derived_system",
"estimated_system",
"test_entry",
}
)
REF_VALUE_METHODS = frozenset(
{
"direct_measurement",
"lab_test",
"field_test",
"questionnaire",
"formula_estimation",
"trend_analysis",
"device_algorithm",
"manual_assessment",
"imported_external",
"unknown",
}
)
REF_VALUE_CONFIDENCE = frozenset({"high", "medium", "low", "unknown"})
VALUE_DATA_TYPES = frozenset({"integer", "decimal", "percentage", "text", "enum"})
def _rules_dict(raw: Any) -> dict:
if not raw:
return {}
if isinstance(raw, dict):
return raw
return {}
def validate_meta_source(source: Optional[str]) -> str:
if not source or not str(source).strip():
raise HTTPException(400, "Quelle (source) ist erforderlich.")
s = str(source).strip()
if s not in REF_VALUE_SOURCES:
raise HTTPException(400, f"Ungültige Quelle: {s}")
return s
def validate_meta_method(method: Optional[str]) -> str:
if not method or not str(method).strip():
raise HTTPException(400, "Methode (method) ist erforderlich.")
m = str(method).strip()
if m not in REF_VALUE_METHODS:
raise HTTPException(400, f"Ungültige Methode: {m}")
return m
def validate_meta_confidence(confidence: Optional[str]) -> str:
if not confidence or not str(confidence).strip():
raise HTTPException(400, "Vertrauensgrad (confidence) ist erforderlich.")
c = str(confidence).strip().lower()
if c not in REF_VALUE_CONFIDENCE:
raise HTTPException(400, f"Ungültiger Vertrauensgrad: {c}")
return c
def resolve_unit_from_type(default_unit: Optional[str]) -> str:
u = (default_unit or "").strip()
if not u:
raise HTTPException(
400,
"Für diesen Kennwert-Typ ist keine Einheit hinterlegt. Bitte im Admin einen Standard unter „Standard-Einheit“ setzen.",
)
return u
def validate_value_for_data_type(
value_data_type: str,
validation_rules_raw: Any,
value_numeric: Optional[float],
value_text: Optional[str],
) -> tuple[Optional[float], Optional[str]]:
"""
Je nach value_data_type den Wert prüfen und (value_numeric, value_text) für die DB liefern.
"""
vdt = (value_data_type or "decimal").strip().lower()
if vdt not in VALUE_DATA_TYPES:
raise HTTPException(400, f"Ungültiger interner Datentyp: {vdt}")
rules = _rules_dict(validation_rules_raw)
if vdt in ("integer", "decimal", "percentage"):
if value_numeric is None:
raise HTTPException(400, "Bitte einen numerischen Wert eingeben.")
v = float(value_numeric)
if vdt == "integer":
if abs(v - round(v)) > 1e-9:
raise HTTPException(400, "Der Wert muss eine ganze Zahl sein.")
v = float(int(round(v)))
pos = bool(rules.get("positive_only"))
mn = rules.get("min")
mx = rules.get("max")
if mn is not None:
mn = float(mn)
if mx is not None:
mx = float(mx)
if vdt == "percentage":
gmn = float(mn) if mn is not None else 0.0
gmx = float(mx) if mx is not None else 100.0
gmn = max(gmn, 0.0)
gmx = min(gmx, 100.0)
if gmn > gmx:
raise HTTPException(500, "Ungültige Plausibilisierung: min > max (Prozent).")
if v < gmn or v > gmx:
raise HTTPException(
400,
f"Prozentwert muss zwischen {gmn} und {gmx} liegen.",
)
if pos and v <= 0:
raise HTTPException(400, "Prozentwert muss positiv sein (laut Konfiguration).")
else:
if pos and v <= 0:
raise HTTPException(400, "Der Wert muss positiv sein (laut Konfiguration).")
if mn is not None and v < mn:
raise HTTPException(400, f"Der Wert muss mindestens {mn} sein.")
if mx is not None and v > mx:
raise HTTPException(400, f"Der Wert darf höchstens {mx} sein.")
return v, None
# text / enum
s = (value_text or "").strip() if value_text is not None else ""
if vdt == "text" and not s and not rules.get("not_empty"):
return None, ""
if rules.get("not_empty") and not s:
raise HTTPException(400, "Der Text darf nicht leer sein.")
ml = rules.get("max_length")
if ml is not None:
try:
ml_int = int(ml)
except (TypeError, ValueError):
ml_int = None
if ml_int is not None and len(s) > ml_int:
raise HTTPException(400, f"Text zu lang (max. {ml_int} Zeichen).")
if vdt == "enum":
allowed = rules.get("allowed_values") or []
if not isinstance(allowed, list):
allowed = []
allowed_str = [str(x).strip() for x in allowed if str(x).strip()]
if not allowed_str:
raise HTTPException(500, "ENUM-Typ ohne erlaubte Werte (Admin-Konfiguration).")
if s not in allowed_str:
raise HTTPException(
400,
f"Ungültiger Wert. Erlaubt: {', '.join(allowed_str)}",
)
return None, s

View File

@ -13,6 +13,7 @@ from psycopg2.extras import Json
from auth import require_admin
from db import get_db, get_cursor, r2d
from reference_value_validation import VALUE_DATA_TYPES
router = APIRouter(prefix="/api/admin/reference-value-types", tags=["admin", "reference-value-types"])
@ -29,11 +30,24 @@ def _serialize_type(row: dict[str, Any]) -> dict[str, Any]:
return out
def _normalize_value_data_type(v: str) -> str:
s = (v or "decimal").strip().lower()
if s not in VALUE_DATA_TYPES:
raise HTTPException(
400,
f"Ungültiger Datentyp: {s}. Erlaubt: {', '.join(sorted(VALUE_DATA_TYPES))}",
)
return s
class ReferenceValueTypeAdminCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=64)
label: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
category: Optional[str] = None
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
@ -42,7 +56,10 @@ class ReferenceValueTypeAdminCreate(BaseModel):
class ReferenceValueTypeAdminUpdate(BaseModel):
label: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
category: Optional[str] = None
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
@ -65,6 +82,13 @@ def _unit_or_none(u: Optional[str]) -> Optional[str]:
return s if s else None
def _cat_or_none(c: Optional[str]) -> Optional[str]:
if c is None:
return None
s = c.strip()
return s if s else None
@router.get("")
def admin_list_reference_value_types(session: dict = Depends(require_admin)):
"""Alle Typen inkl. inaktiver (Admin-Übersicht)."""
@ -72,7 +96,9 @@ def admin_list_reference_value_types(session: dict = Depends(require_admin)):
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at
SELECT
id, key, label, description, category, default_unit, value_data_type,
validation_rules, sort_order, active, metadata, created_at
FROM reference_value_types
ORDER BY sort_order ASC, id ASC
"""
@ -86,7 +112,9 @@ def admin_get_reference_value_type(type_id: int, session: dict = Depends(require
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at
SELECT
id, key, label, description, category, default_unit, value_data_type,
validation_rules, sort_order, active, metadata, created_at
FROM reference_value_types WHERE id = %s
""",
(type_id,),
@ -103,7 +131,18 @@ def admin_create_reference_value_type(
session: dict = Depends(require_admin),
):
key = _normalize_key(body.key)
vdt = _normalize_value_data_type(body.value_data_type)
if not _unit_or_none(body.default_unit):
raise HTTPException(400, "Standard-Einheit ist erforderlich (wird bei der Erfassung fix verwendet).")
meta = body.metadata if body.metadata is not None else {}
rules = body.validation_rules if body.validation_rules is not None else {}
if vdt == "enum":
av = rules.get("allowed_values") if isinstance(rules, dict) else []
if not isinstance(av, list) or not [x for x in av if str(x).strip()]:
raise HTTPException(
400,
"Datentyp ENUM erfordert unter Plausibilisierung eine nicht-leere Liste „Erlaubte Werte“.",
)
du = _unit_or_none(body.default_unit)
with get_db() as conn:
@ -112,15 +151,21 @@ def admin_create_reference_value_type(
cur.execute(
"""
INSERT INTO reference_value_types
(key, label, description, default_unit, sort_order, active, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at
(key, label, description, category, default_unit, value_data_type,
validation_rules, sort_order, active, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, key, label, description, category, default_unit, value_data_type,
validation_rules, sort_order, active, metadata, created_at
""",
(
key,
body.label.strip(),
body.description.strip() if body.description else None,
_cat_or_none(body.category),
du,
vdt,
Json(rules),
body.sort_order,
body.active,
Json(meta),
@ -141,12 +186,20 @@ def admin_update_reference_value_type(
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
if "value_data_type" in patch and patch["value_data_type"] is not None:
patch["value_data_type"] = _normalize_value_data_type(patch["value_data_type"])
if "default_unit" in patch:
patch["default_unit"] = _unit_or_none(patch.get("default_unit"))
if patch["default_unit"] is None:
raise HTTPException(400, "Standard-Einheit darf nicht leer werden.")
if "description" in patch and patch["description"] is not None:
patch["description"] = patch["description"].strip() or None
if "category" in patch:
patch["category"] = _cat_or_none(patch.get("category"))
if "metadata" in patch:
patch["metadata"] = Json(patch["metadata"] if patch["metadata"] is not None else {})
if "validation_rules" in patch:
patch["validation_rules"] = Json(patch["validation_rules"] if patch["validation_rules"] is not None else {})
cols = []
vals = []
@ -161,7 +214,9 @@ def admin_update_reference_value_type(
f"""
UPDATE reference_value_types SET {", ".join(cols)}
WHERE id = %s
RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at
RETURNING
id, key, label, description, category, default_unit, value_data_type,
validation_rules, sort_order, active, metadata, created_at
""",
tuple(vals),
)

View File

@ -1,7 +1,8 @@
"""
Persönliche Referenzwerte (profilorientiert)
Typkatalog system-seeded; Nutzer pflegt historische Werte pro aktivem Profil.
Typkatalog system-seeded; Nutzer pflegt historische Einträge pro aktivem Profil.
Einheit immer aus dem Typ; Wert je value_data_type validiert.
"""
from __future__ import annotations
@ -10,11 +11,21 @@ from decimal import Decimal
from typing import Any, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from auth import require_auth
from db import get_db, get_cursor, r2d
from reference_value_validation import (
REF_VALUE_CONFIDENCE,
REF_VALUE_METHODS,
REF_VALUE_SOURCES,
validate_meta_confidence,
validate_meta_method,
validate_meta_source,
validate_value_for_data_type,
resolve_unit_from_type,
)
from routers.profiles import get_pid
router = APIRouter(prefix="/api", tags=["reference-values"])
@ -36,15 +47,14 @@ def _row_to_api(d: dict[str, Any]) -> dict[str, Any]:
vn = out.get("value_numeric")
if vn is not None and isinstance(vn, Decimal):
out["value_numeric"] = float(vn)
conf = out.get("confidence")
if conf is not None and isinstance(conf, Decimal):
out["confidence"] = float(conf)
return out
def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict:
q = (
"SELECT id, key, label, default_unit, active FROM reference_value_types WHERE key = %s "
"SELECT id, key, label, description, default_unit, active, category, "
"value_data_type, validation_rules, metadata "
"FROM reference_value_types WHERE key = %s "
)
if require_active:
q += "AND active = TRUE "
@ -60,42 +70,34 @@ class ProfileReferenceValueCreate(BaseModel):
effective_date: str
value_numeric: Optional[float] = None
value_text: Optional[str] = None
unit: Optional[str] = Field(None, max_length=32)
source: Optional[str] = None
confidence: Optional[float] = Field(None, ge=0, le=100)
method: Optional[str] = None
source: str = Field(..., min_length=1)
method: str = Field(..., min_length=1)
confidence: str = Field(..., min_length=1)
notes: Optional[str] = None
extra: Optional[dict] = None
@model_validator(mode="after")
def _value_present(self):
has_num = self.value_numeric is not None
has_txt = self.value_text is not None and str(self.value_text).strip() != ""
if not has_num and not has_txt:
raise ValueError("Mindestens value_numeric oder value_text erforderlich")
return self
class ProfileReferenceValueUpdate(BaseModel):
effective_date: Optional[str] = None
value_numeric: Optional[float] = None
value_text: Optional[str] = None
unit: Optional[str] = Field(None, max_length=32)
source: Optional[str] = None
confidence: Optional[float] = Field(None, ge=0, le=100)
method: Optional[str] = None
confidence: Optional[str] = None
notes: Optional[str] = None
extra: Optional[dict] = None
@router.get("/reference-value-types")
def list_reference_value_types(session: dict = Depends(require_auth)):
"""Alle aktiven Referenztyp-Definitionen (für dynamische UI)."""
"""Alle aktiven Referenztyp-Definitionen (dynamische UI inkl. Validierungsmetadaten)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at
SELECT
id, key, label, description, default_unit, sort_order, active,
category, value_data_type, validation_rules, metadata, created_at
FROM reference_value_types
WHERE active = TRUE
ORDER BY sort_order ASC, id ASC
@ -104,6 +106,16 @@ def list_reference_value_types(session: dict = Depends(require_auth)):
return [_row_to_api(r2d(r)) for r in cur.fetchall()]
@router.get("/reference-value-meta/enums")
def list_reference_value_meta_enums(session: dict = Depends(require_auth)):
"""Erlaubte Werte für Quelle, Methode, Vertrauensgrad (Erfassungsdialog)."""
return {
"sources": sorted(REF_VALUE_SOURCES),
"methods": sorted(REF_VALUE_METHODS),
"confidence_levels": sorted(REF_VALUE_CONFIDENCE),
}
@router.get("/profile-reference-values")
def list_profile_reference_values(
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
@ -156,18 +168,17 @@ def create_profile_reference_value(
except ValueError:
raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD")
src = validate_meta_source(body.source)
meth = validate_meta_method(body.method)
conf = validate_meta_confidence(body.confidence)
with get_db() as conn:
cur = get_cursor(conn)
t = _get_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True)
unit = (body.unit or "").strip() or (t.get("default_unit") or "").strip()
if not unit:
raise HTTPException(400, "Bitte eine Einheit angeben (kein Standard für diesen Typ definiert).")
vnum = body.value_numeric
vtxt = (body.value_text.strip() if body.value_text is not None else None) or None
if vnum is None and not vtxt:
raise HTTPException(400, "Mindestens value_numeric oder value_text erforderlich.")
vdt = (t.get("value_data_type") or "decimal").strip().lower()
rules = t.get("validation_rules") or {}
vnum, vtxt = validate_value_for_data_type(vdt, rules, body.value_numeric, body.value_text)
unit = resolve_unit_from_type(t.get("default_unit"))
extra = body.extra if body.extra is not None else {}
cur.execute(
@ -186,9 +197,9 @@ def create_profile_reference_value(
vnum,
vtxt,
unit,
body.source,
body.confidence,
body.method,
src,
conf,
meth,
body.notes,
Json(extra),
),
@ -245,7 +256,7 @@ def update_profile_reference_value(
cur = get_cursor(conn)
cur.execute(
"""
SELECT v.*, rt.default_unit
SELECT v.*, rt.default_unit, rt.value_data_type, rt.validation_rules
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.id = %s AND v.profile_id = %s
@ -257,43 +268,44 @@ def update_profile_reference_value(
raise HTTPException(404, "Eintrag nicht gefunden")
cur_row = r2d(row)
vdt = (cur_row.get("value_data_type") or "decimal").strip().lower()
rules = cur_row.get("validation_rules") or {}
new_ed = patch.get("effective_date", cur_row["effective_date"])
if hasattr(new_ed, "isoformat"):
new_ed = new_ed.isoformat()
new_num = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric")
new_txt_raw = patch["value_text"] if "value_text" in patch else cur_row.get("value_text")
new_txt = (new_txt_raw.strip() if isinstance(new_txt_raw, str) else new_txt_raw) or None
if new_num is not None and isinstance(new_num, Decimal):
new_num = float(new_num)
vn = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric")
vt_raw = patch["value_text"] if "value_text" in patch else cur_row.get("value_text")
if vn is not None and isinstance(vn, Decimal):
vn = float(vn)
if new_num is None and not new_txt:
raise HTTPException(400, "Mindestens value_numeric oder value_text erforderlich.")
vnum, vtxt = validate_value_for_data_type(vdt, rules, vn, vt_raw)
if "unit" in patch:
new_unit = str(patch["unit"]).strip()
if new_unit == "":
default_u = (cur_row.get("default_unit") or "").strip()
nu = default_u or cur_row["unit"]
else:
nu = new_unit
unit = resolve_unit_from_type(cur_row.get("default_unit"))
if "source" in patch:
src = validate_meta_source(patch["source"])
else:
nu = cur_row["unit"]
if not nu:
raise HTTPException(400, "Einheit darf nicht leer sein.")
src = validate_meta_source(cur_row.get("source"))
if "method" in patch:
meth = validate_meta_method(patch["method"])
else:
meth = validate_meta_method(cur_row.get("method"))
if "confidence" in patch:
conf = validate_meta_confidence(patch["confidence"])
else:
conf = validate_meta_confidence(cur_row.get("confidence"))
updates: dict[str, Any] = {
"effective_date": new_ed,
"value_numeric": new_num,
"value_text": new_txt,
"unit": nu,
"value_numeric": vnum,
"value_text": vtxt,
"unit": unit,
"source": src,
"method": meth,
"confidence": conf,
}
if "source" in patch:
updates["source"] = patch["source"]
if "confidence" in patch:
updates["confidence"] = patch["confidence"]
if "method" in patch:
updates["method"] = patch["method"]
if "notes" in patch:
updates["notes"] = patch["notes"]
if "extra" in patch:

View File

@ -9,12 +9,12 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9n"
BUILD_DATE = "2026-04-05"
DB_SCHEMA_VERSION = "20260406" # Migration 037
DB_SCHEMA_VERSION = "20260406b" # Migration 038
MODULE_VERSIONS = {
"auth": "1.2.0",
"profiles": "1.1.0",
"reference_values": "1.1.0",
"reference_values": "1.2.0",
"admin_reference_value_types": "1.0.0",
"weight": "1.0.3",
"circumference": "1.0.1",

View File

@ -2,12 +2,51 @@ import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Gauge, Plus, Pencil, Trash2, Save, X } from 'lucide-react'
import { api } from '../utils/api'
import { VALUE_DATA_TYPE_LABELS } from '../utils/referenceValueMeta'
const VALUE_TYPES = ['integer', 'decimal', 'percentage', 'text', 'enum']
function buildValidationRules(form) {
const t = form.value_data_type
if (t === 'integer' || t === 'decimal') {
const r = { positive_only: !!form.vr_positive_only }
if (form.vr_min !== '' && !Number.isNaN(Number(form.vr_min))) r.min = Number(form.vr_min)
if (form.vr_max !== '' && !Number.isNaN(Number(form.vr_max))) r.max = Number(form.vr_max)
return r
}
if (t === 'percentage') {
const r = { positive_only: !!form.vr_positive_only }
if (form.vr_min !== '' && !Number.isNaN(Number(form.vr_min))) r.min = Number(form.vr_min)
if (form.vr_max !== '' && !Number.isNaN(Number(form.vr_max))) r.max = Number(form.vr_max)
return r
}
if (t === 'text') {
const r = { not_empty: !!form.vr_not_empty }
if (form.vr_max_length !== '' && !Number.isNaN(parseInt(form.vr_max_length, 10))) {
r.max_length = parseInt(form.vr_max_length, 10)
}
return r
}
if (t === 'enum') {
const parts = form.vr_enum_list.split(',').map((s) => s.trim()).filter(Boolean)
return { allowed_values: parts }
}
return {}
}
const emptyForm = () => ({
key: '',
label: '',
category: '',
description: '',
default_unit: '',
value_data_type: 'decimal',
vr_min: '',
vr_max: '',
vr_positive_only: false,
vr_max_length: '',
vr_not_empty: true,
vr_enum_list: '',
sort_order: 0,
active: true,
metadata_json: '{}',
@ -51,17 +90,25 @@ export default function AdminReferenceValueTypesPage() {
}
const openEdit = (r) => {
const vr = r.validation_rules && typeof r.validation_rules === 'object' ? r.validation_rules : {}
const allowed = Array.isArray(vr.allowed_values) ? vr.allowed_values.join(', ') : ''
setEditingId(r.id)
setForm({
key: r.key || '',
label: r.label || '',
category: r.category || '',
description: r.description || '',
default_unit: r.default_unit || '',
value_data_type: r.value_data_type || 'decimal',
vr_min: vr.min != null ? String(vr.min) : '',
vr_max: vr.max != null ? String(vr.max) : '',
vr_positive_only: !!vr.positive_only,
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)
: '{}',
metadata_json: r.metadata && typeof r.metadata === 'object' ? JSON.stringify(r.metadata, null, 2) : '{}',
})
setShowForm(true)
}
@ -77,23 +124,37 @@ export default function AdminReferenceValueTypesPage() {
setError('Bitte einen Anzeigenamen (label) angeben.')
return
}
if (!form.default_unit.trim()) {
setError('Standard-Einheit ist erforderlich (bei Nutzern nicht änderbar).')
return
}
if (form.value_data_type === 'enum' && !form.vr_enum_list.trim()) {
setError('Bei Datentyp „Auswahl (ENUM)“ bitte erlaubte Werte (kommagetrennt) eintragen.')
return
}
let metadata = {}
try {
const mj = form.metadata_json.trim() || '{}'
metadata = JSON.parse(mj)
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
setError('Metadaten müssen ein JSON-Objekt sein.')
setError('Zusatz-Metadaten müssen ein JSON-Objekt sein.')
return
}
} catch {
setError('Metadaten: ungültiges JSON.')
setError('Zusatz-Metadaten: ungültiges JSON.')
return
}
const validation_rules = buildValidationRules(form)
const payload = {
label: form.label.trim(),
category: form.category.trim() || null,
description: form.description.trim() || null,
default_unit: form.default_unit.trim() || null,
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,
@ -140,6 +201,108 @@ export default function AdminReferenceValueTypesPage() {
}
}
const plausibilisierungBlock = () => {
const t = form.value_data_type
if (t === 'integer' || t === 'decimal' || t === 'percentage') {
return (
<>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="settings-page__field" style={{ flex: '1 1 200px', border: 'none', padding: 0 }}>
<label className="settings-page__field-label" htmlFor="ref-vr-min">
Minimum (optional)
</label>
<input
id="ref-vr-min"
type="number"
step="any"
className="form-input"
value={form.vr_min}
onChange={(e) => setForm((f) => ({ ...f, vr_min: e.target.value }))}
/>
</div>
<div className="settings-page__field" style={{ flex: '1 1 200px', border: 'none', padding: 0 }}>
<label className="settings-page__field-label" htmlFor="ref-vr-max">
Maximum (optional){t === 'percentage' ? ' · global max. 100' : ''}
</label>
<input
id="ref-vr-max"
type="number"
step="any"
className="form-input"
value={form.vr_max}
onChange={(e) => setForm((f) => ({ ...f, vr_max: e.target.value }))}
/>
</div>
</div>
<div className="settings-page__field" style={{ border: 'none', padding: '8px 0 0' }}>
<span className="settings-page__field-label">Optionen</span>
<label
htmlFor="ref-vr-pos"
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 14 }}
>
<input
id="ref-vr-pos"
type="checkbox"
checked={form.vr_positive_only}
onChange={(e) => setForm((f) => ({ ...f, vr_positive_only: e.target.checked }))}
/>
Nur positive Zahlen (&gt; 0)
</label>
</div>
</>
)
}
if (t === 'text') {
return (
<>
<div className="settings-page__field" style={{ border: 'none', padding: 0 }}>
<label className="settings-page__field-label" htmlFor="ref-vr-maxlen">
Max. Zeichenzahl (optional)
</label>
<input
id="ref-vr-maxlen"
type="number"
min={1}
className="form-input"
value={form.vr_max_length}
onChange={(e) => setForm((f) => ({ ...f, vr_max_length: e.target.value }))}
/>
</div>
<div className="settings-page__field" style={{ border: 'none', padding: '8px 0 0' }}>
<label htmlFor="ref-vr-ne" style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 14 }}>
<input
id="ref-vr-ne"
type="checkbox"
checked={form.vr_not_empty}
onChange={(e) => setForm((f) => ({ ...f, vr_not_empty: e.target.checked }))}
/>
Nicht leer erlauben
</label>
</div>
</>
)
}
if (t === 'enum') {
return (
<div className="settings-page__field" style={{ border: 'none', padding: 0 }}>
<label className="settings-page__field-label" htmlFor="ref-vr-enum">
Erlaubte Werte (kommagetrennt)
</label>
<textarea
id="ref-vr-enum"
className="form-input"
rows={3}
value={form.vr_enum_list}
onChange={(e) => setForm((f) => ({ ...f, vr_enum_list: e.target.value }))}
placeholder="z. B. niedrig, mittel, hoch"
spellCheck={false}
/>
</div>
)
}
return <p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Keine zusätzlichen Regeln für diesen Typ.</p>
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 48 }}>
@ -156,8 +319,8 @@ export default function AdminReferenceValueTypesPage() {
Referenz-Kennwerte (Typen)
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.6 }}>
Hier definierst du die verfügbaren Kennwert-Typen für Nutzer (Einstellungen → Referenzwerte).
Schlüssel sind nach Anlage fest; Nutzer können nur Werte zu diesen Typen erfassen.
Kategorie, Datentyp und Plausibilisierung steuern die Nutzererfassung. Die Standard-Einheit ist bei der
Eingabe fix; Prozentwerte liegen grundsätzlich zwischen 0 und 100.
</p>
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<Link to="/admin/g/goals" className="btn btn-secondary" style={{ fontSize: 13 }}>
@ -229,6 +392,18 @@ export default function AdminReferenceValueTypesPage() {
placeholder="z. B. Maximale Herzfrequenz"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-admin-cat">
Kategorie (Freitext)
</label>
<input
id="ref-admin-cat"
className="form-input"
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
placeholder="z. B. Herz-Kreislauf"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-admin-desc">
Beschreibung
@ -242,16 +417,37 @@ export default function AdminReferenceValueTypesPage() {
placeholder="Kurze Erklärung für Nutzer und Admins"
/>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-admin-vdt">
Datentyp des Werts
</label>
<select
id="ref-admin-vdt"
className="form-input"
value={form.value_data_type}
onChange={(e) => setForm((f) => ({ ...f, value_data_type: e.target.value }))}
>
{VALUE_TYPES.map((k) => (
<option key={k} value={k}>
{VALUE_DATA_TYPE_LABELS[k] || k}
</option>
))}
</select>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label">Plausibilisierung (je Datentyp)</label>
{plausibilisierungBlock()}
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-admin-unit">
Standard-Einheit
Standard-Einheit (fix bei Nutzererfassung)
</label>
<input
id="ref-admin-unit"
className="form-input"
value={form.default_unit}
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
placeholder="bpm, Stufe, …"
placeholder="bpm, %, Stufe, …"
/>
</div>
<div className="settings-page__field">
@ -292,12 +488,12 @@ export default function AdminReferenceValueTypesPage() {
</div>
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
<label className="settings-page__field-label" htmlFor="ref-admin-meta">
Metadaten (JSON-Objekt)
Zusatz-Metadaten (JSON-Objekt, optional)
</label>
<textarea
id="ref-admin-meta"
className="form-input"
rows={5}
rows={4}
value={form.metadata_json}
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
placeholder="{}"
@ -325,6 +521,8 @@ export default function AdminReferenceValueTypesPage() {
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
<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>
@ -336,6 +534,10 @@ export default function AdminReferenceValueTypesPage() {
<tr key={r.id} style={{ borderBottom: '1px solid var(--border)' }}>
<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>
<td style={{ padding: '10px 6px' }}>
{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>

View File

@ -2,16 +2,17 @@ import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft, Gauge, Pencil, Trash2, Plus } from 'lucide-react'
import { api } from '../utils/api'
import {
labelSource,
labelMethod,
labelConfidence,
VALUE_DATA_TYPE_LABELS,
} from '../utils/referenceValueMeta'
function splitValueInput(raw) {
const s = String(raw).trim()
if (!s) return { value_numeric: null, value_text: null }
const normalized = s.replace(',', '.')
if (/^-?\d+(\.\d+)?$/.test(normalized)) {
const n = Number(normalized)
if (!Number.isNaN(n)) return { value_numeric: n, value_text: null }
}
return { value_numeric: null, value_text: s }
const DEFAULT_FORM_META = {
source: 'manual_user',
method: 'direct_measurement',
confidence: 'medium',
}
function formatEntryValue(row) {
@ -22,8 +23,31 @@ function formatEntryValue(row) {
return row.value_text != null ? String(row.value_text) : '–'
}
function buildValuePayload(selectedType, rawStr) {
const vdt = (selectedType?.value_data_type || 'decimal').toLowerCase()
const s = String(rawStr ?? '').trim()
if (vdt === 'integer' || vdt === 'decimal' || vdt === 'percentage') {
if (!s) {
return { error: 'Bitte einen Wert eingeben.', value_numeric: null, value_text: null }
}
const n = Number(s.replace(',', '.'))
if (Number.isNaN(n)) {
return { error: 'Bitte eine gültige Zahl eingeben.', value_numeric: null, value_text: null }
}
if (vdt === 'integer' && Math.abs(n - Math.round(n)) > 1e-9) {
return { error: 'Bitte eine ganze Zahl eingeben.', value_numeric: null, value_text: null }
}
return { error: null, value_numeric: n, value_text: null }
}
if (vdt === 'text' || vdt === 'enum') {
return { error: null, value_numeric: null, value_text: s }
}
return { error: null, value_numeric: null, value_text: s }
}
export default function ProfileReferenceValuesPage() {
const [types, setTypes] = useState([])
const [metaEnums, setMetaEnums] = useState({ sources: [], methods: [], confidence_levels: [] })
const [selectedKey, setSelectedKey] = useState('')
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
@ -33,8 +57,8 @@ export default function ProfileReferenceValuesPage() {
const [form, setForm] = useState({
effective_date: new Date().toISOString().split('T')[0],
value: '',
unit: '',
notes: '',
...DEFAULT_FORM_META,
})
const selectedType = types.find((t) => t.key === selectedKey)
@ -42,8 +66,16 @@ export default function ProfileReferenceValuesPage() {
const loadTypes = useCallback(async () => {
try {
setLoading(true)
const data = await api.listReferenceValueTypes()
const [data, enums] = await Promise.all([
api.listReferenceValueTypes(),
api.listReferenceValueMetaEnums(),
])
setTypes(Array.isArray(data) ? data : [])
setMetaEnums(
enums && typeof enums === 'object'
? enums
: { sources: [], methods: [], confidence_levels: [] },
)
setError(null)
} catch (e) {
setError(e.message || 'Typen konnten nicht geladen werden')
@ -88,41 +120,127 @@ export default function ProfileReferenceValuesPage() {
setForm({
effective_date: new Date().toISOString().split('T')[0],
value: '',
unit: selectedType?.default_unit || '',
notes: '',
...DEFAULT_FORM_META,
})
}
useEffect(() => {
if (selectedType && !editingId) {
setForm((f) => ({
...f,
unit: selectedType.default_unit || f.unit || '',
}))
const rules = selectedType?.validation_rules && typeof selectedType.validation_rules === 'object'
? selectedType.validation_rules
: {}
const allowedEnum = Array.isArray(rules.allowed_values)
? rules.allowed_values.map((x) => String(x).trim()).filter(Boolean)
: []
const textMaxLen = rules.max_length != null ? parseInt(String(rules.max_length), 10) : null
const vdt = (selectedType?.value_data_type || 'decimal').toLowerCase()
const renderValueField = () => {
if (!selectedType) return null
if (vdt === 'enum') {
if (!allowedEnum.length) {
return (
<p style={{ fontSize: 13, color: '#B45309', margin: 0 }}>
Für diesen Typ sind keine ENUM-Werte konfiguriert. Bitte einen Administrator informieren.
</p>
)
}
return (
<select
id="ref-value"
className="form-input"
required
value={form.value}
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
>
<option value="">— bitte wählen —</option>
{allowedEnum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
)
}
}, [selectedType, editingId])
if (vdt === 'integer') {
return (
<input
id="ref-value"
type="number"
step={1}
className="form-input"
required
value={form.value}
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
placeholder="Ganze Zahl"
/>
)
}
if (vdt === 'decimal' || vdt === 'percentage') {
return (
<input
id="ref-value"
type="number"
step="any"
className="form-input"
required
value={form.value}
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
placeholder={vdt === 'percentage' ? 'z. B. 72.5' : 'Zahl'}
/>
)
}
return (
<textarea
id="ref-value"
className="form-input"
rows={3}
required={!!rules.not_empty}
maxLength={Number.isFinite(textMaxLen) && textMaxLen > 0 ? textMaxLen : undefined}
value={form.value}
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
placeholder="Freitext"
/>
)
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!selectedKey) return
const parts = splitValueInput(form.value)
if (parts.value_numeric == null && !parts.value_text) {
setError('Bitte einen Wert eingeben.')
if (!selectedKey || !selectedType) return
const built = buildValuePayload(selectedType, form.value)
if (built.error) {
setError(built.error)
return
}
const unit = (form.unit || '').trim() || (selectedType?.default_unit || '').trim()
if (!unit) {
setError('Bitte eine Einheit angeben.')
if (
(vdt === 'text' || vdt === 'enum') &&
rules.not_empty &&
!(built.value_text && String(built.value_text).trim())
) {
setError('Bitte einen Text eingeben.')
return
}
if (
vdt === 'text' &&
Number.isFinite(textMaxLen) &&
textMaxLen > 0 &&
built.value_text &&
built.value_text.length > textMaxLen
) {
setError(`Text zu lang (max. ${textMaxLen} Ze).`)
return
}
try {
setError(null)
const payload = {
reference_value_type_key: selectedKey,
effective_date: form.effective_date,
value_numeric: parts.value_numeric,
value_text: parts.value_text,
unit,
value_numeric: built.value_numeric,
value_text: built.value_text,
source: form.source,
method: form.method,
confidence: form.confidence,
notes: form.notes.trim() || null,
}
if (editingId) {
@ -130,7 +248,9 @@ export default function ProfileReferenceValuesPage() {
effective_date: payload.effective_date,
value_numeric: payload.value_numeric,
value_text: payload.value_text,
unit: payload.unit,
source: payload.source,
method: payload.method,
confidence: payload.confidence,
notes: payload.notes,
})
} else {
@ -148,8 +268,10 @@ export default function ProfileReferenceValuesPage() {
setForm({
effective_date: String(row.effective_date || '').slice(0, 10),
value: formatEntryValue(row),
unit: row.unit || selectedType?.default_unit || '',
notes: row.notes || '',
source: row.source || DEFAULT_FORM_META.source,
method: row.method || DEFAULT_FORM_META.method,
confidence: row.confidence || DEFAULT_FORM_META.confidence,
})
}
@ -188,8 +310,8 @@ export default function ProfileReferenceValuesPage() {
Referenzwerte
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Persönliche Kennwerte für das aktive Profil – historisch gespeichert, typgesteuert. Neue Typen
erscheinen automatisch, sobald sie im System ergänzt werden.
Persönliche Kennwerte für das aktive Profil – historisch gespeichert. Der Datentyp und die Plausibilität
werden vom Administrator festgelegt; die Einheit ist fest und nicht änderbar.
</p>
</div>
@ -241,6 +363,18 @@ export default function ProfileReferenceValuesPage() {
{selectedType.description}
</p>
)}
{selectedType && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>
Datentyp:{' '}
<strong>{VALUE_DATA_TYPE_LABELS[vdt] || vdt}</strong>
{selectedType.category ? (
<>
{' '}
· Kategorie: <strong>{selectedType.category}</strong>
</>
) : null}
</p>
)}
</div>
<div className="card section-gap">
@ -266,27 +400,74 @@ export default function ProfileReferenceValuesPage() {
<label className="settings-page__field-label" htmlFor="ref-value">
Wert
</label>
<input
id="ref-value"
type="text"
className="form-input"
placeholder="Zahl oder Text (z. B. 178 oder mittel)"
value={form.value}
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
/>
{renderValueField()}
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-unit">
Einheit
</label>
<input
id="ref-unit"
type="text"
<span className="settings-page__field-label">Einheit (vom Typ, nicht änderbar)</span>
<div
className="form-input"
placeholder={selectedType?.default_unit || 'z. B. bpm'}
value={form.unit}
onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))}
/>
style={{
background: 'var(--surface)',
color: 'var(--text2)',
cursor: 'default',
}}
>
{selectedType?.default_unit?.trim() ? selectedType.default_unit : '— nicht gesetzt —'}
</div>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-source">
Quelle
</label>
<select
id="ref-source"
className="form-input"
required
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
>
{(metaEnums.sources || []).map((k) => (
<option key={k} value={k}>
{labelSource(k)}
</option>
))}
</select>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-method">
Methode
</label>
<select
id="ref-method"
className="form-input"
required
value={form.method}
onChange={(e) => setForm((f) => ({ ...f, method: e.target.value }))}
>
{(metaEnums.methods || []).map((k) => (
<option key={k} value={k}>
{labelMethod(k)}
</option>
))}
</select>
</div>
<div className="settings-page__field">
<label className="settings-page__field-label" htmlFor="ref-confidence">
Vertrauensgrad
</label>
<select
id="ref-confidence"
className="form-input"
required
value={form.confidence}
onChange={(e) => setForm((f) => ({ ...f, confidence: e.target.value }))}
>
{(metaEnums.confidence_levels || []).map((k) => (
<option key={k} value={k}>
{labelConfidence(k)}
</option>
))}
</select>
</div>
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
<label className="settings-page__field-label" htmlFor="ref-notes">
@ -298,11 +479,11 @@ export default function ProfileReferenceValuesPage() {
rows={3}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="Kontext, Messmethode …"
placeholder="Zusatzkontext …"
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
<button type="submit" className="btn btn-primary btn-full">
<button type="submit" className="btn btn-primary btn-full" disabled={vdt === 'enum' && !allowedEnum.length}>
{editingId ? 'Speichern' : 'Hinzufügen'}
</button>
{editingId && (
@ -326,13 +507,16 @@ export default function ProfileReferenceValuesPage() {
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
<th style={{ padding: '8px 6px' }}>Datum</th>
<th style={{ padding: '8px 6px' }}>Wert</th>
<th style={{ padding: '8px 6px' }}>Einheit</th>
<th style={{ padding: '8px 6px', width: 100 }} />
<th style={{ padding: '8px 6px' }}>Einh.</th>
<th style={{ padding: '8px 6px' }}>Quelle</th>
<th style={{ padding: '8px 6px' }}>Methode</th>
<th style={{ padding: '8px 6px' }}>Vertr.</th>
<th style={{ padding: '8px 6px', width: 96 }} />
</tr>
</thead>
<tbody>
@ -343,6 +527,9 @@ export default function ProfileReferenceValuesPage() {
</td>
<td style={{ padding: '10px 6px' }}>{formatEntryValue(row)}</td>
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{row.unit}</td>
<td style={{ padding: '10px 6px' }}>{labelSource(row.source)}</td>
<td style={{ padding: '10px 6px' }}>{labelMethod(row.method)}</td>
<td style={{ padding: '10px 6px' }}>{labelConfidence(row.confidence)}</td>
<td style={{ padding: '6px', textAlign: 'right' }}>
<button
type="button"

View File

@ -42,6 +42,7 @@ export const api = {
// Persönliche Referenzwerte (Profil, historisch)
listReferenceValueTypes: () => req('/reference-value-types'),
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
listProfileReferenceValues: (typeKey) =>
req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`),
createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)),

View File

@ -0,0 +1,49 @@
/** Labels für Referenzwert-Metadaten (Erfassung). */
export const REF_SOURCE_LABELS = {
manual_user: 'Manuell (Nutzer)',
manual_admin: 'Manuell (Admin)',
import_device: 'Import Gerät',
import_app: 'Import App',
derived_system: 'Abgeleitet (System)',
estimated_system: 'Geschätzt (System)',
test_entry: 'Testeintrag',
}
export const REF_METHOD_LABELS = {
direct_measurement: 'Direkte Messung',
lab_test: 'Labortest',
field_test: 'Feldtest',
questionnaire: 'Fragebogen',
formula_estimation: 'Formel-Schätzung',
trend_analysis: 'Trendanalyse',
device_algorithm: 'Geräte-Algorithmus',
manual_assessment: 'Manuelle Einschätzung',
imported_external: 'Extern importiert',
unknown: 'Unbekannt',
}
export const REF_CONFIDENCE_LABELS = {
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
unknown: 'Unbekannt',
}
export function labelSource(k) {
return REF_SOURCE_LABELS[k] || k || '–'
}
export function labelMethod(k) {
return REF_METHOD_LABELS[k] || k || '–'
}
export function labelConfidence(k) {
return REF_CONFIDENCE_LABELS[k] || k || '–'
}
export const VALUE_DATA_TYPE_LABELS = {
integer: 'Ganzzahl',
decimal: 'Dezimalzahl',
percentage: 'Prozent',
text: 'Text',
enum: 'Auswahl (ENUM)',
}