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 auth import require_admin
|
||||||
from db import get_db, get_cursor, r2d
|
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"])
|
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
|
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):
|
class ReferenceValueTypeAdminCreate(BaseModel):
|
||||||
key: str = Field(..., min_length=1, max_length=64)
|
key: str = Field(..., min_length=1, max_length=64)
|
||||||
label: str = Field(..., min_length=1, max_length=200)
|
label: str = Field(..., min_length=1, max_length=200)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
default_unit: Optional[str] = Field(None, max_length=32)
|
default_unit: Optional[str] = Field(None, max_length=32)
|
||||||
|
value_data_type: str = "decimal"
|
||||||
|
validation_rules: Optional[dict] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
active: bool = True
|
active: bool = True
|
||||||
metadata: Optional[dict] = None
|
metadata: Optional[dict] = None
|
||||||
|
|
@ -42,7 +56,10 @@ class ReferenceValueTypeAdminCreate(BaseModel):
|
||||||
class ReferenceValueTypeAdminUpdate(BaseModel):
|
class ReferenceValueTypeAdminUpdate(BaseModel):
|
||||||
label: Optional[str] = Field(None, min_length=1, max_length=200)
|
label: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
default_unit: Optional[str] = Field(None, max_length=32)
|
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
|
sort_order: Optional[int] = None
|
||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
metadata: Optional[dict] = None
|
metadata: Optional[dict] = None
|
||||||
|
|
@ -65,6 +82,13 @@ def _unit_or_none(u: Optional[str]) -> Optional[str]:
|
||||||
return s if s else None
|
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("")
|
@router.get("")
|
||||||
def admin_list_reference_value_types(session: dict = Depends(require_admin)):
|
def admin_list_reference_value_types(session: dict = Depends(require_admin)):
|
||||||
"""Alle Typen inkl. inaktiver (Admin-Übersicht)."""
|
"""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 = get_cursor(conn)
|
||||||
cur.execute(
|
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
|
FROM reference_value_types
|
||||||
ORDER BY sort_order ASC, id ASC
|
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 = get_cursor(conn)
|
||||||
cur.execute(
|
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
|
FROM reference_value_types WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(type_id,),
|
(type_id,),
|
||||||
|
|
@ -103,7 +131,18 @@ def admin_create_reference_value_type(
|
||||||
session: dict = Depends(require_admin),
|
session: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
key = _normalize_key(body.key)
|
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 {}
|
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)
|
du = _unit_or_none(body.default_unit)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -112,15 +151,21 @@ def admin_create_reference_value_type(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reference_value_types
|
INSERT INTO reference_value_types
|
||||||
(key, label, description, default_unit, sort_order, active, metadata)
|
(key, label, description, category, default_unit, value_data_type,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
validation_rules, sort_order, active, metadata)
|
||||||
RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at
|
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,
|
key,
|
||||||
body.label.strip(),
|
body.label.strip(),
|
||||||
body.description.strip() if body.description else None,
|
body.description.strip() if body.description else None,
|
||||||
|
_cat_or_none(body.category),
|
||||||
du,
|
du,
|
||||||
|
vdt,
|
||||||
|
Json(rules),
|
||||||
body.sort_order,
|
body.sort_order,
|
||||||
body.active,
|
body.active,
|
||||||
Json(meta),
|
Json(meta),
|
||||||
|
|
@ -141,12 +186,20 @@ def admin_update_reference_value_type(
|
||||||
if not patch:
|
if not patch:
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
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:
|
if "default_unit" in patch:
|
||||||
patch["default_unit"] = _unit_or_none(patch.get("default_unit"))
|
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:
|
if "description" in patch and patch["description"] is not None:
|
||||||
patch["description"] = patch["description"].strip() or None
|
patch["description"] = patch["description"].strip() or None
|
||||||
|
if "category" in patch:
|
||||||
|
patch["category"] = _cat_or_none(patch.get("category"))
|
||||||
if "metadata" in patch:
|
if "metadata" in patch:
|
||||||
patch["metadata"] = Json(patch["metadata"] if patch["metadata"] is not None else {})
|
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 = []
|
cols = []
|
||||||
vals = []
|
vals = []
|
||||||
|
|
@ -161,7 +214,9 @@ def admin_update_reference_value_type(
|
||||||
f"""
|
f"""
|
||||||
UPDATE reference_value_types SET {", ".join(cols)}
|
UPDATE reference_value_types SET {", ".join(cols)}
|
||||||
WHERE id = %s
|
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),
|
tuple(vals),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Persönliche Referenzwerte (profilorientiert)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,11 +11,21 @@ from decimal import Decimal
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
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 psycopg2.extras import Json
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from db import get_db, get_cursor, r2d
|
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
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["reference-values"])
|
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")
|
vn = out.get("value_numeric")
|
||||||
if vn is not None and isinstance(vn, Decimal):
|
if vn is not None and isinstance(vn, Decimal):
|
||||||
out["value_numeric"] = float(vn)
|
out["value_numeric"] = float(vn)
|
||||||
conf = out.get("confidence")
|
|
||||||
if conf is not None and isinstance(conf, Decimal):
|
|
||||||
out["confidence"] = float(conf)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict:
|
def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict:
|
||||||
q = (
|
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:
|
if require_active:
|
||||||
q += "AND active = TRUE "
|
q += "AND active = TRUE "
|
||||||
|
|
@ -60,42 +70,34 @@ class ProfileReferenceValueCreate(BaseModel):
|
||||||
effective_date: str
|
effective_date: str
|
||||||
value_numeric: Optional[float] = None
|
value_numeric: Optional[float] = None
|
||||||
value_text: Optional[str] = None
|
value_text: Optional[str] = None
|
||||||
unit: Optional[str] = Field(None, max_length=32)
|
source: str = Field(..., min_length=1)
|
||||||
source: Optional[str] = None
|
method: str = Field(..., min_length=1)
|
||||||
confidence: Optional[float] = Field(None, ge=0, le=100)
|
confidence: str = Field(..., min_length=1)
|
||||||
method: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
extra: Optional[dict] = 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):
|
class ProfileReferenceValueUpdate(BaseModel):
|
||||||
effective_date: Optional[str] = None
|
effective_date: Optional[str] = None
|
||||||
value_numeric: Optional[float] = None
|
value_numeric: Optional[float] = None
|
||||||
value_text: Optional[str] = None
|
value_text: Optional[str] = None
|
||||||
unit: Optional[str] = Field(None, max_length=32)
|
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
confidence: Optional[float] = Field(None, ge=0, le=100)
|
|
||||||
method: Optional[str] = None
|
method: Optional[str] = None
|
||||||
|
confidence: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
extra: Optional[dict] = None
|
extra: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reference-value-types")
|
@router.get("/reference-value-types")
|
||||||
def list_reference_value_types(session: dict = Depends(require_auth)):
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
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
|
FROM reference_value_types
|
||||||
WHERE active = TRUE
|
WHERE active = TRUE
|
||||||
ORDER BY sort_order ASC, id ASC
|
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()]
|
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")
|
@router.get("/profile-reference-values")
|
||||||
def list_profile_reference_values(
|
def list_profile_reference_values(
|
||||||
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
|
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
|
||||||
|
|
@ -156,18 +168,17 @@ def create_profile_reference_value(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
t = _get_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True)
|
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()
|
vdt = (t.get("value_data_type") or "decimal").strip().lower()
|
||||||
if not unit:
|
rules = t.get("validation_rules") or {}
|
||||||
raise HTTPException(400, "Bitte eine Einheit angeben (kein Standard für diesen Typ definiert).")
|
vnum, vtxt = validate_value_for_data_type(vdt, rules, body.value_numeric, body.value_text)
|
||||||
|
unit = resolve_unit_from_type(t.get("default_unit"))
|
||||||
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.")
|
|
||||||
|
|
||||||
extra = body.extra if body.extra is not None else {}
|
extra = body.extra if body.extra is not None else {}
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -186,9 +197,9 @@ def create_profile_reference_value(
|
||||||
vnum,
|
vnum,
|
||||||
vtxt,
|
vtxt,
|
||||||
unit,
|
unit,
|
||||||
body.source,
|
src,
|
||||||
body.confidence,
|
conf,
|
||||||
body.method,
|
meth,
|
||||||
body.notes,
|
body.notes,
|
||||||
Json(extra),
|
Json(extra),
|
||||||
),
|
),
|
||||||
|
|
@ -245,7 +256,7 @@ def update_profile_reference_value(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT v.*, rt.default_unit
|
SELECT v.*, rt.default_unit, rt.value_data_type, rt.validation_rules
|
||||||
FROM profile_reference_values v
|
FROM profile_reference_values v
|
||||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
||||||
WHERE v.id = %s AND v.profile_id = %s
|
WHERE v.id = %s AND v.profile_id = %s
|
||||||
|
|
@ -257,43 +268,44 @@ def update_profile_reference_value(
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||||
cur_row = r2d(row)
|
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"])
|
new_ed = patch.get("effective_date", cur_row["effective_date"])
|
||||||
if hasattr(new_ed, "isoformat"):
|
if hasattr(new_ed, "isoformat"):
|
||||||
new_ed = new_ed.isoformat()
|
new_ed = new_ed.isoformat()
|
||||||
|
|
||||||
new_num = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric")
|
vn = 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")
|
vt_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 vn is not None and isinstance(vn, Decimal):
|
||||||
if new_num is not None and isinstance(new_num, Decimal):
|
vn = float(vn)
|
||||||
new_num = float(new_num)
|
|
||||||
|
|
||||||
if new_num is None and not new_txt:
|
vnum, vtxt = validate_value_for_data_type(vdt, rules, vn, vt_raw)
|
||||||
raise HTTPException(400, "Mindestens value_numeric oder value_text erforderlich.")
|
|
||||||
|
|
||||||
if "unit" in patch:
|
unit = resolve_unit_from_type(cur_row.get("default_unit"))
|
||||||
new_unit = str(patch["unit"]).strip()
|
|
||||||
if new_unit == "":
|
if "source" in patch:
|
||||||
default_u = (cur_row.get("default_unit") or "").strip()
|
src = validate_meta_source(patch["source"])
|
||||||
nu = default_u or cur_row["unit"]
|
|
||||||
else:
|
|
||||||
nu = new_unit
|
|
||||||
else:
|
else:
|
||||||
nu = cur_row["unit"]
|
src = validate_meta_source(cur_row.get("source"))
|
||||||
if not nu:
|
if "method" in patch:
|
||||||
raise HTTPException(400, "Einheit darf nicht leer sein.")
|
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] = {
|
updates: dict[str, Any] = {
|
||||||
"effective_date": new_ed,
|
"effective_date": new_ed,
|
||||||
"value_numeric": new_num,
|
"value_numeric": vnum,
|
||||||
"value_text": new_txt,
|
"value_text": vtxt,
|
||||||
"unit": nu,
|
"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:
|
if "notes" in patch:
|
||||||
updates["notes"] = patch["notes"]
|
updates["notes"] = patch["notes"]
|
||||||
if "extra" in patch:
|
if "extra" in patch:
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
APP_VERSION = "0.9n"
|
APP_VERSION = "0.9n"
|
||||||
BUILD_DATE = "2026-04-05"
|
BUILD_DATE = "2026-04-05"
|
||||||
DB_SCHEMA_VERSION = "20260406" # Migration 037
|
DB_SCHEMA_VERSION = "20260406b" # Migration 038
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
"profiles": "1.1.0",
|
"profiles": "1.1.0",
|
||||||
"reference_values": "1.1.0",
|
"reference_values": "1.2.0",
|
||||||
"admin_reference_value_types": "1.0.0",
|
"admin_reference_value_types": "1.0.0",
|
||||||
"weight": "1.0.3",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,51 @@ import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Gauge, Plus, Pencil, Trash2, Save, X } from 'lucide-react'
|
import { Gauge, Plus, Pencil, Trash2, Save, X } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
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 = () => ({
|
const emptyForm = () => ({
|
||||||
key: '',
|
key: '',
|
||||||
label: '',
|
label: '',
|
||||||
|
category: '',
|
||||||
description: '',
|
description: '',
|
||||||
default_unit: '',
|
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,
|
sort_order: 0,
|
||||||
active: true,
|
active: true,
|
||||||
metadata_json: '{}',
|
metadata_json: '{}',
|
||||||
|
|
@ -51,17 +90,25 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (r) => {
|
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)
|
setEditingId(r.id)
|
||||||
setForm({
|
setForm({
|
||||||
key: r.key || '',
|
key: r.key || '',
|
||||||
label: r.label || '',
|
label: r.label || '',
|
||||||
|
category: r.category || '',
|
||||||
description: r.description || '',
|
description: r.description || '',
|
||||||
default_unit: r.default_unit || '',
|
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,
|
sort_order: r.sort_order ?? 0,
|
||||||
active: !!r.active,
|
active: !!r.active,
|
||||||
metadata_json: r.metadata && typeof r.metadata === 'object'
|
metadata_json: r.metadata && typeof r.metadata === 'object' ? JSON.stringify(r.metadata, null, 2) : '{}',
|
||||||
? JSON.stringify(r.metadata, null, 2)
|
|
||||||
: '{}',
|
|
||||||
})
|
})
|
||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
@ -77,23 +124,37 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
setError('Bitte einen Anzeigenamen (label) angeben.')
|
setError('Bitte einen Anzeigenamen (label) angeben.')
|
||||||
return
|
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 = {}
|
let metadata = {}
|
||||||
try {
|
try {
|
||||||
const mj = form.metadata_json.trim() || '{}'
|
const mj = form.metadata_json.trim() || '{}'
|
||||||
metadata = JSON.parse(mj)
|
metadata = JSON.parse(mj)
|
||||||
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Metadaten: ungültiges JSON.')
|
setError('Zusatz-Metadaten: ungültiges JSON.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validation_rules = buildValidationRules(form)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
label: form.label.trim(),
|
label: form.label.trim(),
|
||||||
|
category: form.category.trim() || null,
|
||||||
description: form.description.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,
|
sort_order: Number(form.sort_order) || 0,
|
||||||
active: !!form.active,
|
active: !!form.active,
|
||||||
metadata,
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
|
@ -156,8 +319,8 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
Referenz-Kennwerte (Typen)
|
Referenz-Kennwerte (Typen)
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.6 }}>
|
<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).
|
Kategorie, Datentyp und Plausibilisierung steuern die Nutzererfassung. Die Standard-Einheit ist bei der
|
||||||
Schlüssel sind nach Anlage fest; Nutzer können nur Werte zu diesen Typen erfassen.
|
Eingabe fix; Prozentwerte liegen grundsätzlich zwischen 0 und 100.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<Link to="/admin/g/goals" className="btn btn-secondary" style={{ fontSize: 13 }}>
|
<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"
|
placeholder="z. B. Maximale Herzfrequenz"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="settings-page__field">
|
||||||
<label className="settings-page__field-label" htmlFor="ref-admin-desc">
|
<label className="settings-page__field-label" htmlFor="ref-admin-desc">
|
||||||
Beschreibung
|
Beschreibung
|
||||||
|
|
@ -242,16 +417,37 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
placeholder="Kurze Erklärung für Nutzer und Admins"
|
placeholder="Kurze Erklärung für Nutzer und Admins"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="settings-page__field">
|
||||||
<label className="settings-page__field-label" htmlFor="ref-admin-unit">
|
<label className="settings-page__field-label" htmlFor="ref-admin-unit">
|
||||||
Standard-Einheit
|
Standard-Einheit (fix bei Nutzererfassung)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="ref-admin-unit"
|
id="ref-admin-unit"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={form.default_unit}
|
value={form.default_unit}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
|
||||||
placeholder="bpm, Stufe, …"
|
placeholder="bpm, %, Stufe, …"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-page__field">
|
<div className="settings-page__field">
|
||||||
|
|
@ -292,12 +488,12 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
||||||
<label className="settings-page__field-label" htmlFor="ref-admin-meta">
|
<label className="settings-page__field-label" htmlFor="ref-admin-meta">
|
||||||
Metadaten (JSON-Objekt)
|
Zusatz-Metadaten (JSON-Objekt, optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="ref-admin-meta"
|
id="ref-admin-meta"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
rows={5}
|
rows={4}
|
||||||
value={form.metadata_json}
|
value={form.metadata_json}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
|
||||||
placeholder="{}"
|
placeholder="{}"
|
||||||
|
|
@ -325,6 +521,8 @@ export default function AdminReferenceValueTypesPage() {
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
<th style={{ padding: '8px 6px' }}>Key</th>
|
<th style={{ padding: '8px 6px' }}>Key</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Name</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' }}>Einheit</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Sort.</th>
|
<th style={{ padding: '8px 6px' }}>Sort.</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Aktiv</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)' }}>
|
<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', fontFamily: 'monospace', fontSize: 13 }}>{r.key}</td>
|
||||||
<td style={{ padding: '10px 6px' }}>{r.label}</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', color: 'var(--text2)' }}>{r.default_unit || '–'}</td>
|
||||||
<td style={{ padding: '10px 6px' }}>{r.sort_order}</td>
|
<td style={{ padding: '10px 6px' }}>{r.sort_order}</td>
|
||||||
<td style={{ padding: '10px 6px' }}>{r.active ? '✓' : '–'}</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 { Link } from 'react-router-dom'
|
||||||
import { ArrowLeft, Gauge, Pencil, Trash2, Plus } from 'lucide-react'
|
import { ArrowLeft, Gauge, Pencil, Trash2, Plus } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import {
|
||||||
|
labelSource,
|
||||||
|
labelMethod,
|
||||||
|
labelConfidence,
|
||||||
|
VALUE_DATA_TYPE_LABELS,
|
||||||
|
} from '../utils/referenceValueMeta'
|
||||||
|
|
||||||
function splitValueInput(raw) {
|
const DEFAULT_FORM_META = {
|
||||||
const s = String(raw).trim()
|
source: 'manual_user',
|
||||||
if (!s) return { value_numeric: null, value_text: null }
|
method: 'direct_measurement',
|
||||||
const normalized = s.replace(',', '.')
|
confidence: 'medium',
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntryValue(row) {
|
function formatEntryValue(row) {
|
||||||
|
|
@ -22,8 +23,31 @@ function formatEntryValue(row) {
|
||||||
return row.value_text != null ? String(row.value_text) : '–'
|
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() {
|
export default function ProfileReferenceValuesPage() {
|
||||||
const [types, setTypes] = useState([])
|
const [types, setTypes] = useState([])
|
||||||
|
const [metaEnums, setMetaEnums] = useState({ sources: [], methods: [], confidence_levels: [] })
|
||||||
const [selectedKey, setSelectedKey] = useState('')
|
const [selectedKey, setSelectedKey] = useState('')
|
||||||
const [entries, setEntries] = useState([])
|
const [entries, setEntries] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -33,8 +57,8 @@ export default function ProfileReferenceValuesPage() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
effective_date: new Date().toISOString().split('T')[0],
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
value: '',
|
value: '',
|
||||||
unit: '',
|
|
||||||
notes: '',
|
notes: '',
|
||||||
|
...DEFAULT_FORM_META,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedType = types.find((t) => t.key === selectedKey)
|
const selectedType = types.find((t) => t.key === selectedKey)
|
||||||
|
|
@ -42,8 +66,16 @@ export default function ProfileReferenceValuesPage() {
|
||||||
const loadTypes = useCallback(async () => {
|
const loadTypes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const data = await api.listReferenceValueTypes()
|
const [data, enums] = await Promise.all([
|
||||||
|
api.listReferenceValueTypes(),
|
||||||
|
api.listReferenceValueMetaEnums(),
|
||||||
|
])
|
||||||
setTypes(Array.isArray(data) ? data : [])
|
setTypes(Array.isArray(data) ? data : [])
|
||||||
|
setMetaEnums(
|
||||||
|
enums && typeof enums === 'object'
|
||||||
|
? enums
|
||||||
|
: { sources: [], methods: [], confidence_levels: [] },
|
||||||
|
)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Typen konnten nicht geladen werden')
|
setError(e.message || 'Typen konnten nicht geladen werden')
|
||||||
|
|
@ -88,41 +120,127 @@ export default function ProfileReferenceValuesPage() {
|
||||||
setForm({
|
setForm({
|
||||||
effective_date: new Date().toISOString().split('T')[0],
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
value: '',
|
value: '',
|
||||||
unit: selectedType?.default_unit || '',
|
|
||||||
notes: '',
|
notes: '',
|
||||||
|
...DEFAULT_FORM_META,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const rules = selectedType?.validation_rules && typeof selectedType.validation_rules === 'object'
|
||||||
if (selectedType && !editingId) {
|
? selectedType.validation_rules
|
||||||
setForm((f) => ({
|
: {}
|
||||||
...f,
|
const allowedEnum = Array.isArray(rules.allowed_values)
|
||||||
unit: selectedType.default_unit || f.unit || '',
|
? 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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedKey) return
|
if (!selectedKey || !selectedType) return
|
||||||
const parts = splitValueInput(form.value)
|
|
||||||
if (parts.value_numeric == null && !parts.value_text) {
|
const built = buildValuePayload(selectedType, form.value)
|
||||||
setError('Bitte einen Wert eingeben.')
|
if (built.error) {
|
||||||
|
setError(built.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const unit = (form.unit || '').trim() || (selectedType?.default_unit || '').trim()
|
if (
|
||||||
if (!unit) {
|
(vdt === 'text' || vdt === 'enum') &&
|
||||||
setError('Bitte eine Einheit angeben.')
|
rules.not_empty &&
|
||||||
|
!(built.value_text && String(built.value_text).trim())
|
||||||
|
) {
|
||||||
|
setError('Bitte einen Text eingeben.')
|
||||||
return
|
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 {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
const payload = {
|
const payload = {
|
||||||
reference_value_type_key: selectedKey,
|
reference_value_type_key: selectedKey,
|
||||||
effective_date: form.effective_date,
|
effective_date: form.effective_date,
|
||||||
value_numeric: parts.value_numeric,
|
value_numeric: built.value_numeric,
|
||||||
value_text: parts.value_text,
|
value_text: built.value_text,
|
||||||
unit,
|
source: form.source,
|
||||||
|
method: form.method,
|
||||||
|
confidence: form.confidence,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
}
|
}
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
|
|
@ -130,7 +248,9 @@ export default function ProfileReferenceValuesPage() {
|
||||||
effective_date: payload.effective_date,
|
effective_date: payload.effective_date,
|
||||||
value_numeric: payload.value_numeric,
|
value_numeric: payload.value_numeric,
|
||||||
value_text: payload.value_text,
|
value_text: payload.value_text,
|
||||||
unit: payload.unit,
|
source: payload.source,
|
||||||
|
method: payload.method,
|
||||||
|
confidence: payload.confidence,
|
||||||
notes: payload.notes,
|
notes: payload.notes,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -148,8 +268,10 @@ export default function ProfileReferenceValuesPage() {
|
||||||
setForm({
|
setForm({
|
||||||
effective_date: String(row.effective_date || '').slice(0, 10),
|
effective_date: String(row.effective_date || '').slice(0, 10),
|
||||||
value: formatEntryValue(row),
|
value: formatEntryValue(row),
|
||||||
unit: row.unit || selectedType?.default_unit || '',
|
|
||||||
notes: row.notes || '',
|
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
|
Referenzwerte
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
<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
|
Persönliche Kennwerte für das aktive Profil – historisch gespeichert. Der Datentyp und die Plausibilität
|
||||||
erscheinen automatisch, sobald sie im System ergänzt werden.
|
werden vom Administrator festgelegt; die Einheit ist fest und nicht änderbar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -241,6 +363,18 @@ export default function ProfileReferenceValuesPage() {
|
||||||
{selectedType.description}
|
{selectedType.description}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
|
|
@ -266,27 +400,74 @@ export default function ProfileReferenceValuesPage() {
|
||||||
<label className="settings-page__field-label" htmlFor="ref-value">
|
<label className="settings-page__field-label" htmlFor="ref-value">
|
||||||
Wert
|
Wert
|
||||||
</label>
|
</label>
|
||||||
<input
|
{renderValueField()}
|
||||||
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 }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-page__field">
|
<div className="settings-page__field">
|
||||||
<label className="settings-page__field-label" htmlFor="ref-unit">
|
<span className="settings-page__field-label">Einheit (vom Typ, nicht änderbar)</span>
|
||||||
Einheit
|
<div
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="ref-unit"
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder={selectedType?.default_unit || 'z. B. bpm'}
|
style={{
|
||||||
value={form.unit}
|
background: 'var(--surface)',
|
||||||
onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))}
|
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>
|
||||||
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
||||||
<label className="settings-page__field-label" htmlFor="ref-notes">
|
<label className="settings-page__field-label" htmlFor="ref-notes">
|
||||||
|
|
@ -298,11 +479,11 @@ export default function ProfileReferenceValuesPage() {
|
||||||
rows={3}
|
rows={3}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
placeholder="Kontext, Messmethode …"
|
placeholder="Zusatzkontext …"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
<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'}
|
{editingId ? 'Speichern' : 'Hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
{editingId && (
|
{editingId && (
|
||||||
|
|
@ -326,13 +507,16 @@ export default function ProfileReferenceValuesPage() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
<th style={{ padding: '8px 6px' }}>Datum</th>
|
<th style={{ padding: '8px 6px' }}>Datum</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Wert</th>
|
<th style={{ padding: '8px 6px' }}>Wert</th>
|
||||||
<th style={{ padding: '8px 6px' }}>Einheit</th>
|
<th style={{ padding: '8px 6px' }}>Einh.</th>
|
||||||
<th style={{ padding: '8px 6px', width: 100 }} />
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -343,6 +527,9 @@ export default function ProfileReferenceValuesPage() {
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 6px' }}>{formatEntryValue(row)}</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', 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' }}>
|
<td style={{ padding: '6px', textAlign: 'right' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export const api = {
|
||||||
|
|
||||||
// Persönliche Referenzwerte (Profil, historisch)
|
// Persönliche Referenzwerte (Profil, historisch)
|
||||||
listReferenceValueTypes: () => req('/reference-value-types'),
|
listReferenceValueTypes: () => req('/reference-value-types'),
|
||||||
|
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
|
||||||
listProfileReferenceValues: (typeKey) =>
|
listProfileReferenceValues: (typeKey) =>
|
||||||
req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`),
|
req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`),
|
||||||
createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)),
|
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