feat: Enhance reference value types management with validation rules and metadata
- 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:
parent
f04318f76a
commit
45e4e64f15
54
backend/migrations/038_reference_value_type_metadata.sql
Normal file
54
backend/migrations/038_reference_value_type_metadata.sql
Normal 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
|
||||
);
|
||||
167
backend/reference_value_validation.py
Normal file
167
backend/reference_value_validation.py
Normal 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
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (> 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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
49
frontend/src/utils/referenceValueMeta.js
Normal file
49
frontend/src/utils/referenceValueMeta.js
Normal 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)',
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user