diff --git a/backend/migrations/038_reference_value_type_metadata.sql b/backend/migrations/038_reference_value_type_metadata.sql new file mode 100644 index 0000000..07464a3 --- /dev/null +++ b/backend/migrations/038_reference_value_type_metadata.sql @@ -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 +); diff --git a/backend/reference_value_validation.py b/backend/reference_value_validation.py new file mode 100644 index 0000000..f42abbe --- /dev/null +++ b/backend/reference_value_validation.py @@ -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 diff --git a/backend/routers/admin_reference_value_types.py b/backend/routers/admin_reference_value_types.py index 204abf4..dad2a15 100644 --- a/backend/routers/admin_reference_value_types.py +++ b/backend/routers/admin_reference_value_types.py @@ -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), ) diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py index 5bb0a1d..9fb0efd 100644 --- a/backend/routers/reference_values.py +++ b/backend/routers/reference_values.py @@ -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: diff --git a/backend/version.py b/backend/version.py index 47988ba..3421439 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/pages/AdminReferenceValueTypesPage.jsx b/frontend/src/pages/AdminReferenceValueTypesPage.jsx index 7317762..39b1114 100644 --- a/frontend/src/pages/AdminReferenceValueTypesPage.jsx +++ b/frontend/src/pages/AdminReferenceValueTypesPage.jsx @@ -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 ( + <> +
Keine zusätzlichen Regeln für diesen Typ.
+ } + if (loading) { return (- 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.