diff --git a/backend/main.py b/backend/main.py index e0798ee..850d589 100644 --- a/backend/main.py +++ b/backend/main.py @@ -29,6 +29,7 @@ from routers import charts # Phase 0c Multi-Layer Architecture from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog from routers import workflows # Phase 2 Workflow Engine - Execution from routers import reference_values # Persönliche Referenzwerte (Profil) +from routers import admin_reference_value_types # Admin: Referenzwert-Typen # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -117,6 +118,7 @@ app.include_router(charts.router) # /api/charts/* (Phase 0c Charts app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog) app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution) app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values +app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/routers/admin_reference_value_types.py b/backend/routers/admin_reference_value_types.py new file mode 100644 index 0000000..204abf4 --- /dev/null +++ b/backend/routers/admin_reference_value_types.py @@ -0,0 +1,192 @@ +""" +Admin: Referenzwert-Typen (Katalog für persönliche Referenzwerte). + +Nur Admins; Nutzer sehen nur aktive Typen über /api/reference-value-types. +""" +import re +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from psycopg2 import errors as pg_errors +from psycopg2.extras import Json + +from auth import require_admin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin/reference-value-types", tags=["admin", "reference-value-types"]) + +KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$") + + +def _serialize_type(row: dict[str, Any]) -> dict[str, Any]: + if not row: + return row + out = dict(row) + ca = out.get("created_at") + if ca is not None and hasattr(ca, "isoformat"): + out["created_at"] = ca.isoformat() + return out + + +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 + default_unit: Optional[str] = Field(None, max_length=32) + sort_order: int = 0 + active: bool = True + metadata: Optional[dict] = None + + +class ReferenceValueTypeAdminUpdate(BaseModel): + label: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + default_unit: Optional[str] = Field(None, max_length=32) + sort_order: Optional[int] = None + active: Optional[bool] = None + metadata: Optional[dict] = None + + +def _normalize_key(key: str) -> str: + k = key.strip().lower() + if not KEY_PATTERN.match(k): + raise HTTPException( + 400, + "Ungültiger Schlüssel: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.", + ) + return k + + +def _unit_or_none(u: Optional[str]) -> Optional[str]: + if u is None: + return None + s = u.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).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at + FROM reference_value_types + ORDER BY sort_order ASC, id ASC + """ + ) + return [_serialize_type(r2d(r)) for r in cur.fetchall()] + + +@router.get("/{type_id}") +def admin_get_reference_value_type(type_id: int, session: dict = Depends(require_admin)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at + FROM reference_value_types WHERE id = %s + """, + (type_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Typ nicht gefunden") + return _serialize_type(r2d(row)) + + +@router.post("") +def admin_create_reference_value_type( + body: ReferenceValueTypeAdminCreate, + session: dict = Depends(require_admin), +): + key = _normalize_key(body.key) + meta = body.metadata if body.metadata is not None else {} + du = _unit_or_none(body.default_unit) + + with get_db() as conn: + cur = get_cursor(conn) + try: + 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, + body.label.strip(), + body.description.strip() if body.description else None, + du, + body.sort_order, + body.active, + Json(meta), + ), + ) + return _serialize_type(r2d(cur.fetchone())) + except pg_errors.UniqueViolation: + raise HTTPException(409, "Ein Typ mit diesem Schlüssel existiert bereits.") + + +@router.put("/{type_id}") +def admin_update_reference_value_type( + type_id: int, + body: ReferenceValueTypeAdminUpdate, + session: dict = Depends(require_admin), +): + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + + if "default_unit" in patch: + patch["default_unit"] = _unit_or_none(patch.get("default_unit")) + if "description" in patch and patch["description"] is not None: + patch["description"] = patch["description"].strip() or None + if "metadata" in patch: + patch["metadata"] = Json(patch["metadata"] if patch["metadata"] is not None else {}) + + cols = [] + vals = [] + for k, v in patch.items(): + cols.append(f"{k} = %s") + vals.append(v) + vals.append(type_id) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + UPDATE reference_value_types SET {", ".join(cols)} + WHERE id = %s + RETURNING id, key, label, description, default_unit, sort_order, active, metadata, created_at + """, + tuple(vals), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Typ nicht gefunden") + return _serialize_type(r2d(row)) + + +@router.delete("/{type_id}") +def admin_delete_reference_value_type(type_id: int, session: dict = Depends(require_admin)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT COUNT(*) AS c FROM profile_reference_values WHERE reference_value_type_id = %s", + (type_id,), + ) + n = cur.fetchone()["c"] + if n > 0: + raise HTTPException( + 409, + f"Es gibt noch {n} gespeicherte Referenzwert(e) zu diesem Typ. " + "Bitte zuerst löschen oder den Typ deaktivieren (active = aus).", + ) + cur.execute("DELETE FROM reference_value_types WHERE id = %s RETURNING id", (type_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Typ nicht gefunden") + return {"ok": True} diff --git a/backend/version.py b/backend/version.py index 904d454..47988ba 100644 --- a/backend/version.py +++ b/backend/version.py @@ -14,7 +14,8 @@ DB_SCHEMA_VERSION = "20260406" # Migration 037 MODULE_VERSIONS = { "auth": "1.2.0", "profiles": "1.1.0", - "reference_values": "1.0.0", + "reference_values": "1.1.0", + "admin_reference_value_types": "1.0.0", "weight": "1.0.3", "circumference": "1.0.1", "caliper": "1.0.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index affacd5..aaae9db 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminFocusAreasPage from './pages/AdminFocusAreasPage' +import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage' import AdminHomePage from './pages/AdminHomePage' import AdminUsersPage from './pages/AdminUsersPage' import AdminSystemPage from './pages/AdminSystemPage' @@ -244,6 +245,7 @@ function AppShell() { }/> }/> }/> + }/> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 80da080..396e792 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -86,6 +86,11 @@ export const ADMIN_GROUPS = [ label: 'Focus Areas', description: 'Dynamische Fokusbereiche und Kategorien.', }, + { + to: '/admin/reference-value-types', + label: 'Referenz-Kennwerte', + description: 'Typen für persönliche Referenzwerte (Schlüssel, Namen, Metadaten).', + }, ], }, { diff --git a/frontend/src/pages/AdminReferenceValueTypesPage.jsx b/frontend/src/pages/AdminReferenceValueTypesPage.jsx new file mode 100644 index 0000000..725bbc2 --- /dev/null +++ b/frontend/src/pages/AdminReferenceValueTypesPage.jsx @@ -0,0 +1,361 @@ +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' + +const emptyForm = () => ({ + key: '', + label: '', + description: '', + default_unit: '', + sort_order: 0, + active: true, + metadata_json: '{}', +}) + +export default function AdminReferenceValueTypesPage() { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [toast, setToast] = useState(null) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(emptyForm()) + const [showForm, setShowForm] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await api.adminListReferenceValueTypes() + setRows(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const showToast = (msg) => { + setToast(msg) + setTimeout(() => setToast(null), 2500) + } + + const openCreate = () => { + setEditingId(null) + setForm(emptyForm()) + setShowForm(true) + } + + const openEdit = (r) => { + setEditingId(r.id) + setForm({ + key: r.key || '', + label: r.label || '', + description: r.description || '', + default_unit: r.default_unit || '', + sort_order: r.sort_order ?? 0, + active: !!r.active, + metadata_json: r.metadata && typeof r.metadata === 'object' + ? JSON.stringify(r.metadata, null, 2) + : '{}', + }) + setShowForm(true) + } + + const closeForm = () => { + setShowForm(false) + setEditingId(null) + setForm(emptyForm()) + } + + const handleSave = async () => { + if (!form.label.trim()) { + setError('Bitte einen Anzeigenamen (label) angeben.') + 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.') + return + } + } catch { + setError('Metadaten: ungültiges JSON.') + return + } + + const payload = { + label: form.label.trim(), + description: form.description.trim() || null, + default_unit: form.default_unit.trim() || null, + sort_order: Number(form.sort_order) || 0, + active: !!form.active, + metadata, + } + + try { + setError(null) + if (editingId) { + await api.adminUpdateReferenceValueType(editingId, payload) + showToast('Typ gespeichert') + } else { + if (!form.key.trim()) { + setError('Bitte einen technischen Schlüssel (key) angeben.') + return + } + await api.adminCreateReferenceValueType({ + key: form.key.trim().toLowerCase(), + ...payload, + }) + showToast('Typ angelegt') + } + closeForm() + await load() + } catch (e) { + setError(e.message || 'Speichern fehlgeschlagen') + } + } + + const handleDelete = async (r) => { + if ( + !confirm( + `Typ „${r.label}“ (${r.key}) wirklich löschen? Nur möglich, wenn keine Nutzer-Einträge existieren.`, + ) + ) { + return + } + try { + setError(null) + await api.adminDeleteReferenceValueType(r.id) + showToast('Typ gelöscht') + await load() + } catch (e) { + setError(e.message || 'Löschen fehlgeschlagen') + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

+ + Referenz-Kennwerte (Typen) +

+

+ 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. +

+
+ + ← Zur Gruppe „Ziele & Fokus“ + + +
+
+ + {toast && ( +
+ {toast} +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {showForm && ( +
+
+ {editingId ? 'Typ bearbeiten' : 'Neuer Typ'} + +
+
+
+ + setForm((f) => ({ ...f, key: e.target.value }))} + placeholder="z. B. max_heart_rate" + autoComplete="off" + /> +

+ Kleinbuchstaben, Ziffern, Unterstriche; nach Anlage nicht änderbar. +

+
+
+ + setForm((f) => ({ ...f, label: e.target.value }))} + placeholder="z. B. Maximale Herzfrequenz" + /> +
+
+ +