Indvidual Dashboard V0.9 #67
|
|
@ -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 workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
||||||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||||
|
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
|
||||||
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
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(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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
192
backend/routers/admin_reference_value_types.py
Normal file
192
backend/routers/admin_reference_value_types.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -14,7 +14,8 @@ DB_SCHEMA_VERSION = "20260406" # Migration 037
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
"profiles": "1.1.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",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||||
|
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
||||||
import AdminHomePage from './pages/AdminHomePage'
|
import AdminHomePage from './pages/AdminHomePage'
|
||||||
import AdminUsersPage from './pages/AdminUsersPage'
|
import AdminUsersPage from './pages/AdminUsersPage'
|
||||||
import AdminSystemPage from './pages/AdminSystemPage'
|
import AdminSystemPage from './pages/AdminSystemPage'
|
||||||
|
|
@ -244,6 +245,7 @@ function AppShell() {
|
||||||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||||
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||||
|
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,11 @@ export const ADMIN_GROUPS = [
|
||||||
label: 'Focus Areas',
|
label: 'Focus Areas',
|
||||||
description: 'Dynamische Fokusbereiche und Kategorien.',
|
description: 'Dynamische Fokusbereiche und Kategorien.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/reference-value-types',
|
||||||
|
label: 'Referenz-Kennwerte',
|
||||||
|
description: 'Typen für persönliche Referenzwerte (Schlüssel, Namen, Metadaten).',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
361
frontend/src/pages/AdminReferenceValueTypesPage.jsx
Normal file
361
frontend/src/pages/AdminReferenceValueTypesPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
|
<h1 style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 22, margin: 0 }}>
|
||||||
|
<Gauge size={26} color="var(--accent)" />
|
||||||
|
Referenz-Kennwerte (Typen)
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.6 }}>
|
||||||
|
Hier definierst du die verfügbaren Kennwert-Typen für Nutzer (Einstellungen → Referenzwerte).
|
||||||
|
Schlüssel sind nach Anlage fest; Nutzer können nur Werte zu diesen Typen erfassen.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<Link to="/admin/g/goals" className="btn btn-secondary" style={{ fontSize: 13 }}>
|
||||||
|
← Zur Gruppe „Ziele & Fokus“
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
<Plus size={16} style={{ marginRight: 6 }} />
|
||||||
|
Neuer Typ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="card" style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', marginBottom: 16 }}>
|
||||||
|
<p style={{ color: '#DC2626', margin: 0, fontSize: 14 }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
{editingId ? 'Typ bearbeiten' : 'Neuer Typ'}
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={closeForm} style={{ padding: '6px 10px' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-key">
|
||||||
|
Technischer Schlüssel (key)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-key"
|
||||||
|
className="form-input"
|
||||||
|
disabled={!!editingId}
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
|
||||||
|
placeholder="z. B. max_heart_rate"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '6px 0 0' }}>
|
||||||
|
Kleinbuchstaben, Ziffern, Unterstriche; nach Anlage nicht änderbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-label">
|
||||||
|
Anzeigename
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-label"
|
||||||
|
className="form-input"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||||
|
placeholder="z. B. Maximale Herzfrequenz"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-desc">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-admin-desc"
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="Kurze Erklärung für Nutzer und Admins"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: '1 1 160px' }}>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-unit">
|
||||||
|
Standard-Einheit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-unit"
|
||||||
|
className="form-input"
|
||||||
|
value={form.default_unit}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
|
||||||
|
placeholder="bpm, Stufe, …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 0 120px' }}>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-sort">
|
||||||
|
Sortierung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-sort"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={form.sort_order}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, sort_order: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0 0 140px', alignSelf: 'flex-end', paddingBottom: 4 }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 14 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Aktiv (nutzer sichtbar)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-admin-meta">
|
||||||
|
Metadaten (JSON-Objekt)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-admin-meta"
|
||||||
|
className="form-input"
|
||||||
|
rows={5}
|
||||||
|
value={form.metadata_json}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
|
||||||
|
placeholder='{}'
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleSave}>
|
||||||
|
<Save size={16} style={{ marginRight: 6 }} />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={closeForm}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Alle Typen ({rows.length})</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Key</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Name</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Einheit</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Sort.</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Aktiv</th>
|
||||||
|
<th style={{ padding: '8px 6px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '10px 6px', fontFamily: 'monospace', fontSize: 13 }}>{r.key}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{r.label}</td>
|
||||||
|
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.default_unit || '–'}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{r.sort_order}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{r.active ? '✓' : '–'}</td>
|
||||||
|
<td style={{ padding: '6px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', marginRight: 6 }}
|
||||||
|
onClick={() => openEdit(r)}
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', color: 'var(--danger)' }}
|
||||||
|
onClick={() => handleDelete(r)}
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0, fontSize: 14 }}>Noch keine Typen – „Neuer Typ“ anlegen.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,12 @@ export const api = {
|
||||||
updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)),
|
updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)),
|
||||||
deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),
|
deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Admin: Referenzwert-Typen (Katalog)
|
||||||
|
adminListReferenceValueTypes: () => req('/admin/reference-value-types'),
|
||||||
|
adminCreateReferenceValueType: (d) => req('/admin/reference-value-types', json(d)),
|
||||||
|
adminUpdateReferenceValueType: (id, d) => req(`/admin/reference-value-types/${id}`, jput(d)),
|
||||||
|
adminDeleteReferenceValueType: (id) => req(`/admin/reference-value-types/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Weight
|
// Weight
|
||||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user