feat: Introduce admin reference value types management in API and UI
- Added new routes and API endpoints for managing reference value types in the admin section. - Updated the frontend to include navigation and components for reference value types management. - Enhanced the backend to support the new reference value types in the data layer and versioning.
This commit is contained in:
parent
f0e6fd04fb
commit
ab616ba044
|
|
@ -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("/")
|
||||
|
|
|
|||
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 = {
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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)),
|
||||
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
|
||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user