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)',
}