feat: Update reference values and introduce pilot visualization module
- Bumped version of reference_values module to 1.3.0. - Added new imports and functionality for reference values in the backend, enhancing data retrieval. - Introduced a new PilotVizPage in the frontend for visualizing data, linked from the SettingsPage for easy access. - Updated routing in App.jsx to include the new pilot visualization route.
This commit is contained in:
parent
3e916c082c
commit
932bceb1e1
|
|
@ -21,6 +21,8 @@ Modules:
|
|||
- utils: Shared functions (confidence, baseline, outliers)
|
||||
- training_profile: Template-based training evaluation scaffold (Layer 1)
|
||||
Import: ``from data_layer.training_profile import resolve_training_evaluation``
|
||||
- reference_values: Profile reference value reads + summary (Layer 1)
|
||||
Import: ``from data_layer import reference_values`` or submodule imports
|
||||
|
||||
Phase 0c: Multi-Layer Architecture
|
||||
Version: 1.0
|
||||
|
|
|
|||
179
backend/data_layer/reference_values.py
Normal file
179
backend/data_layer/reference_values.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""
|
||||
Reference values — Layer 1 (read paths + structured rows)
|
||||
|
||||
Structured retrieval for profile reference values and the active type catalog.
|
||||
Mutations (INSERT/UPDATE/DELETE) stay in routers/reference_values.py with validation.
|
||||
|
||||
Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from db import get_cursor, get_db, r2d
|
||||
|
||||
|
||||
def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float)."""
|
||||
if not d:
|
||||
return d
|
||||
out = dict(d)
|
||||
for k in ("effective_date", "created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if v is not None and hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
vn = out.get("value_numeric")
|
||||
if vn is not None and isinstance(vn, Decimal):
|
||||
out["value_numeric"] = float(vn)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_reference_type_by_key(cur, key: str, require_active: bool = True) -> Optional[dict[str, Any]]:
|
||||
"""Single type row by key; for use inside router transactions (shared cursor)."""
|
||||
q = (
|
||||
"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 "
|
||||
cur.execute(q, (key,))
|
||||
row = cur.fetchone()
|
||||
return r2d(row) if row else None
|
||||
|
||||
|
||||
def list_active_reference_value_types_data() -> list[dict[str, Any]]:
|
||||
"""All active reference_value_types rows, catalog sort order."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
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
|
||||
"""
|
||||
)
|
||||
return [normalize_reference_row(r2d(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def list_profile_reference_values_for_type(
|
||||
profile_id: str, type_key: str
|
||||
) -> Optional[list[dict[str, Any]]]:
|
||||
"""
|
||||
Historical entries for one type, newest first.
|
||||
Returns None if type_key does not resolve to an active type.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
t = fetch_reference_type_by_key(cur, type_key, require_active=True)
|
||||
if not t:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
v.id,
|
||||
v.profile_id,
|
||||
v.reference_value_type_id,
|
||||
v.effective_date,
|
||||
v.value_numeric,
|
||||
v.value_text,
|
||||
v.unit,
|
||||
v.source,
|
||||
v.confidence,
|
||||
v.method,
|
||||
v.notes,
|
||||
v.extra,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
rt.key AS type_key,
|
||||
rt.label AS type_label
|
||||
FROM profile_reference_values v
|
||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
||||
WHERE v.profile_id = %s AND rt.key = %s
|
||||
ORDER BY v.effective_date DESC, v.created_at DESC
|
||||
""",
|
||||
(profile_id, t["key"]),
|
||||
)
|
||||
return [normalize_reference_row(r2d(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def build_summary_tiles_from_ranked_rows(raw_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Build /summary.tile payloads from SQL rows (rn 1..2 per type).
|
||||
Each row still contains rn, type_sort_order, value_data_type before stripping.
|
||||
"""
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
skip_cols = frozenset({"rn", "type_sort_order", "value_data_type"})
|
||||
for row in raw_rows:
|
||||
rn = int(row.get("rn") or 0)
|
||||
key = row["type_key"]
|
||||
if key not in by_key:
|
||||
by_key[key] = {
|
||||
"type_key": key,
|
||||
"type_label": row.get("type_label") or key,
|
||||
"value_data_type": (row.get("value_data_type") or "decimal").strip().lower(),
|
||||
"sort_key": (row.get("type_sort_order") or 0, key),
|
||||
"latest": None,
|
||||
"previous": None,
|
||||
}
|
||||
entry = {k: v for k, v in row.items() if k not in skip_cols}
|
||||
api_entry = normalize_reference_row(entry)
|
||||
if rn == 1:
|
||||
by_key[key]["latest"] = api_entry
|
||||
elif rn == 2:
|
||||
by_key[key]["previous"] = api_entry
|
||||
|
||||
tiles = sorted(by_key.values(), key=lambda t: t["sort_key"])
|
||||
for t in tiles:
|
||||
t.pop("sort_key", None)
|
||||
return tiles
|
||||
|
||||
|
||||
def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]:
|
||||
"""latest + previous entry per type (active types only), tiles sorted like catalog."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
v.id,
|
||||
v.profile_id,
|
||||
v.reference_value_type_id,
|
||||
v.effective_date,
|
||||
v.value_numeric,
|
||||
v.value_text,
|
||||
v.unit,
|
||||
v.source,
|
||||
v.confidence,
|
||||
v.method,
|
||||
v.notes,
|
||||
v.extra,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
rt.key AS type_key,
|
||||
rt.label AS type_label,
|
||||
rt.sort_order AS type_sort_order,
|
||||
rt.value_data_type,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY v.reference_value_type_id
|
||||
ORDER BY v.effective_date DESC, v.created_at DESC
|
||||
) AS rn
|
||||
FROM profile_reference_values v
|
||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
||||
WHERE v.profile_id = %s AND rt.active = TRUE
|
||||
)
|
||||
SELECT * FROM ranked WHERE rn <= 2
|
||||
ORDER BY type_sort_order ASC, type_key ASC, rn ASC
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
raw_rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
tiles = build_summary_tiles_from_ranked_rows(raw_rows)
|
||||
return {"tiles": tiles}
|
||||
|
|
@ -3,6 +3,8 @@ Persönliche Referenzwerte (profilorientiert)
|
|||
|
||||
Typkatalog system-seeded; Nutzer pflegt historische Einträge pro aktivem Profil.
|
||||
Einheit immer aus dem Typ; Wert je value_data_type validiert.
|
||||
|
||||
Reads (Liste, Summary, Katalog) → data_layer.reference_values (Layer 1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -15,6 +17,13 @@ from pydantic import BaseModel, Field
|
|||
from psycopg2.extras import Json
|
||||
|
||||
from auth import require_auth
|
||||
from data_layer.reference_values import (
|
||||
fetch_reference_type_by_key,
|
||||
get_profile_reference_values_summary,
|
||||
list_active_reference_value_types_data,
|
||||
list_profile_reference_values_for_type,
|
||||
normalize_reference_row,
|
||||
)
|
||||
from db import get_db, get_cursor, r2d
|
||||
from reference_value_validation import (
|
||||
REF_VALUE_CONFIDENCE,
|
||||
|
|
@ -32,40 +41,6 @@ from routers.profiles import get_pid
|
|||
router = APIRouter(prefix="/api", tags=["reference-values"])
|
||||
|
||||
|
||||
def _row_to_api(d: dict[str, Any]) -> dict[str, Any]:
|
||||
if not d:
|
||||
return d
|
||||
out = dict(d)
|
||||
ed = out.get("effective_date")
|
||||
if ed is not None and hasattr(ed, "isoformat"):
|
||||
out["effective_date"] = ed.isoformat()
|
||||
ca = out.get("created_at")
|
||||
if ca is not None and hasattr(ca, "isoformat"):
|
||||
out["created_at"] = ca.isoformat()
|
||||
ua = out.get("updated_at")
|
||||
if ua is not None and hasattr(ua, "isoformat"):
|
||||
out["updated_at"] = ua.isoformat()
|
||||
vn = out.get("value_numeric")
|
||||
if vn is not None and isinstance(vn, Decimal):
|
||||
out["value_numeric"] = float(vn)
|
||||
return out
|
||||
|
||||
|
||||
def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict:
|
||||
q = (
|
||||
"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 "
|
||||
cur.execute(q, (key,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Referenztyp nicht gefunden")
|
||||
return r2d(row)
|
||||
|
||||
|
||||
class ProfileReferenceValueCreate(BaseModel):
|
||||
reference_value_type_key: str = Field(..., min_length=1, max_length=64)
|
||||
effective_date: str
|
||||
|
|
@ -92,19 +67,7 @@ class ProfileReferenceValueUpdate(BaseModel):
|
|||
@router.get("/reference-value-types")
|
||||
def list_reference_value_types(session: dict = Depends(require_auth)):
|
||||
"""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,
|
||||
category, value_data_type, validation_rules, metadata, created_at
|
||||
FROM reference_value_types
|
||||
WHERE active = TRUE
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
"""
|
||||
)
|
||||
return [_row_to_api(r2d(r)) for r in cur.fetchall()]
|
||||
return list_active_reference_value_types_data()
|
||||
|
||||
|
||||
@router.get("/reference-value-meta/enums")
|
||||
|
|
@ -127,71 +90,7 @@ def profile_reference_values_summary(
|
|||
plus der unmittelbar vorherige (gleiche Sortierung wie Liste), für Tendenz-Anzeigen.
|
||||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
v.id,
|
||||
v.profile_id,
|
||||
v.reference_value_type_id,
|
||||
v.effective_date,
|
||||
v.value_numeric,
|
||||
v.value_text,
|
||||
v.unit,
|
||||
v.source,
|
||||
v.confidence,
|
||||
v.method,
|
||||
v.notes,
|
||||
v.extra,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
rt.key AS type_key,
|
||||
rt.label AS type_label,
|
||||
rt.sort_order AS type_sort_order,
|
||||
rt.value_data_type,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY v.reference_value_type_id
|
||||
ORDER BY v.effective_date DESC, v.created_at DESC
|
||||
) AS rn
|
||||
FROM profile_reference_values v
|
||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
||||
WHERE v.profile_id = %s AND rt.active = TRUE
|
||||
)
|
||||
SELECT * FROM ranked WHERE rn <= 2
|
||||
ORDER BY type_sort_order ASC, type_key ASC, rn ASC
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
raw_rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
by_key: dict[str, dict[str, Any]] = {}
|
||||
skip_cols = frozenset({"rn", "type_sort_order", "value_data_type"})
|
||||
for row in raw_rows:
|
||||
rn = int(row.get("rn") or 0)
|
||||
key = row["type_key"]
|
||||
if key not in by_key:
|
||||
by_key[key] = {
|
||||
"type_key": key,
|
||||
"type_label": row.get("type_label") or key,
|
||||
"value_data_type": (row.get("value_data_type") or "decimal").strip().lower(),
|
||||
"sort_key": (row.get("type_sort_order") or 0, key),
|
||||
"latest": None,
|
||||
"previous": None,
|
||||
}
|
||||
entry = {k: v for k, v in row.items() if k not in skip_cols}
|
||||
api_entry = _row_to_api(entry)
|
||||
if rn == 1:
|
||||
by_key[key]["latest"] = api_entry
|
||||
elif rn == 2:
|
||||
by_key[key]["previous"] = api_entry
|
||||
|
||||
tiles = sorted(by_key.values(), key=lambda t: t["sort_key"])
|
||||
for t in tiles:
|
||||
t.pop("sort_key", None)
|
||||
|
||||
return {"tiles": tiles}
|
||||
return get_profile_reference_values_summary(pid)
|
||||
|
||||
|
||||
@router.get("/profile-reference-values")
|
||||
|
|
@ -202,36 +101,10 @@ def list_profile_reference_values(
|
|||
):
|
||||
"""Historische Einträge eines Typs für das aktive Profil (neueste zuerst)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
t = _get_type_by_key(cur, type_key, require_active=True)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
v.id,
|
||||
v.profile_id,
|
||||
v.reference_value_type_id,
|
||||
v.effective_date,
|
||||
v.value_numeric,
|
||||
v.value_text,
|
||||
v.unit,
|
||||
v.source,
|
||||
v.confidence,
|
||||
v.method,
|
||||
v.notes,
|
||||
v.extra,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
rt.key AS type_key,
|
||||
rt.label AS type_label
|
||||
FROM profile_reference_values v
|
||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
||||
WHERE v.profile_id = %s AND rt.key = %s
|
||||
ORDER BY v.effective_date DESC, v.created_at DESC
|
||||
""",
|
||||
(pid, t["key"]),
|
||||
)
|
||||
return [_row_to_api(r2d(r)) for r in cur.fetchall()]
|
||||
rows = list_profile_reference_values_for_type(pid, type_key)
|
||||
if rows is None:
|
||||
raise HTTPException(404, "Referenztyp nicht gefunden")
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/profile-reference-values")
|
||||
|
|
@ -252,7 +125,9 @@ def create_profile_reference_value(
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
t = _get_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True)
|
||||
t = fetch_reference_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True)
|
||||
if not t:
|
||||
raise HTTPException(404, "Referenztyp nicht gefunden")
|
||||
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)
|
||||
|
|
@ -309,7 +184,7 @@ def create_profile_reference_value(
|
|||
""",
|
||||
(new_id, pid),
|
||||
)
|
||||
return _row_to_api(r2d(cur.fetchone()))
|
||||
return normalize_reference_row(r2d(cur.fetchone()))
|
||||
|
||||
|
||||
@router.put("/profile-reference-values/{entry_id}")
|
||||
|
|
@ -424,7 +299,7 @@ def update_profile_reference_value(
|
|||
""",
|
||||
(entry_id, pid),
|
||||
)
|
||||
return _row_to_api(r2d(cur.fetchone()))
|
||||
return normalize_reference_row(r2d(cur.fetchone()))
|
||||
|
||||
|
||||
@router.delete("/profile-reference-values/{entry_id}")
|
||||
|
|
|
|||
48
backend/tests/test_reference_values_data_layer.py
Normal file
48
backend/tests/test_reference_values_data_layer.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Unit tests for data_layer.reference_values (summary assembly, no DB)."""
|
||||
|
||||
from data_layer.reference_values import build_summary_tiles_from_ranked_rows
|
||||
|
||||
|
||||
def test_build_summary_tiles_single_type_two_rows():
|
||||
raw = [
|
||||
{
|
||||
"type_key": "hr_max",
|
||||
"type_label": "HF max",
|
||||
"type_sort_order": 1,
|
||||
"value_data_type": "decimal",
|
||||
"rn": 1,
|
||||
"id": 2,
|
||||
"effective_date": "2026-04-01",
|
||||
"value_numeric": 180.0,
|
||||
"value_text": None,
|
||||
"unit": "bpm",
|
||||
},
|
||||
{
|
||||
"type_key": "hr_max",
|
||||
"type_label": "HF max",
|
||||
"type_sort_order": 1,
|
||||
"value_data_type": "decimal",
|
||||
"rn": 2,
|
||||
"id": 1,
|
||||
"effective_date": "2026-03-01",
|
||||
"value_numeric": 175.0,
|
||||
"value_text": None,
|
||||
"unit": "bpm",
|
||||
},
|
||||
]
|
||||
tiles = build_summary_tiles_from_ranked_rows(raw)
|
||||
assert len(tiles) == 1
|
||||
t = tiles[0]
|
||||
assert t["type_key"] == "hr_max"
|
||||
assert t["latest"]["value_numeric"] == 180.0
|
||||
assert t["previous"]["value_numeric"] == 175.0
|
||||
assert "sort_key" not in t
|
||||
|
||||
|
||||
def test_build_summary_tiles_multi_type_order():
|
||||
raw = [
|
||||
{"type_key": "b", "type_label": "B", "type_sort_order": 2, "value_data_type": "decimal", "rn": 1, "id": 1},
|
||||
{"type_key": "a", "type_label": "A", "type_sort_order": 1, "value_data_type": "decimal", "rn": 1, "id": 2},
|
||||
]
|
||||
tiles = build_summary_tiles_from_ranked_rows(raw)
|
||||
assert [x["type_key"] for x in tiles] == ["a", "b"]
|
||||
|
|
@ -14,7 +14,7 @@ DB_SCHEMA_VERSION = "20260406b" # Migration 038
|
|||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
"profiles": "1.1.0",
|
||||
"reference_values": "1.2.0",
|
||||
"reference_values": "1.3.0",
|
||||
"admin_reference_value_types": "1.0.0",
|
||||
"weight": "1.0.3",
|
||||
"circumference": "1.0.1",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import Analysis from './pages/Analysis'
|
|||
import SettingsPage from './pages/SettingsPage'
|
||||
import SettingsShell from './layouts/SettingsShell'
|
||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||
import PilotVizPage from './pages/PilotVizPage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
|
|
@ -253,6 +254,7 @@ function AppShell() {
|
|||
</Route>
|
||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../utils/api'
|
||||
|
||||
function formatEntryValue(row) {
|
||||
if (row.value_numeric != null && row.value_numeric !== '') {
|
||||
const n = Number(row.value_numeric)
|
||||
return Number.isFinite(n) ? String(n) : String(row.value_numeric)
|
||||
}
|
||||
return row.value_text != null ? String(row.value_text) : '–'
|
||||
}
|
||||
|
||||
export default function ReferenceValuesSummaryWidget() {
|
||||
const [tiles, setTiles] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await api.listProfileReferenceValuesSummary()
|
||||
if (!cancelled) {
|
||||
setTiles(Array.isArray(data?.tiles) ? data.tiles : [])
|
||||
setErr(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setErr(e.message || 'Laden fehlgeschlagen')
|
||||
setTiles([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (err) {
|
||||
return <div style={{ fontSize: 13, color: 'var(--danger)' }}>{err}</div>
|
||||
}
|
||||
if (tiles.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||
Noch keine Referenzwerte erfasst.{' '}
|
||||
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
|
||||
Zur Erfassung
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ref-value-tiles-grid">
|
||||
{tiles
|
||||
.filter((t) => t?.latest)
|
||||
.map((tile) => (
|
||||
<div
|
||||
key={tile.type_key}
|
||||
className="card"
|
||||
style={{ margin: 0, padding: 14, boxSizing: 'border-box' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)' }}>{tile.type_label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 6 }}>
|
||||
{formatEntryValue(tile.latest)}
|
||||
{tile.latest.unit ? (
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text2)', marginLeft: 6 }}>
|
||||
{tile.latest.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>
|
||||
Stand {String(tile.latest.effective_date || '').slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/pages/PilotVizPage.jsx
Normal file
49
frontend/src/pages/PilotVizPage.jsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { FlaskConical } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getPilotWidgetsInOrder } from '../pilot/widgetRegistry'
|
||||
|
||||
/**
|
||||
* Pilot: Widget-Schicht (Layer 3b) parallel zum produktiven Dashboard.
|
||||
* Daten für Referenzwerte: Layer 1 (backend/data_layer/reference_values.py) → API.
|
||||
*/
|
||||
export default function PilotVizPage() {
|
||||
const widgets = getPilotWidgetsInOrder()
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 900, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="btn btn-secondary"
|
||||
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||
>
|
||||
← Einstellungen
|
||||
</Link>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<FlaskConical size={26} color="var(--accent)" />
|
||||
Pilot: Visualisierungs-Module
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Testumgebung für die zukünftige Widget-Registry. Die produktive Übersicht und der Verlauf bleiben
|
||||
unverändert. Referenzwerte-Summary wird über die gleiche API geladen wie in der Erfassungs-UI, angeboten
|
||||
durch den Data Layer (Layer 1).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{widgets.map((def) => {
|
||||
const { Component } = def
|
||||
return (
|
||||
<section key={def.id} className="card section-gap" style={{ overflow: 'hidden' }}>
|
||||
<div className="card-title">{def.title}</div>
|
||||
{def.description && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
<Component />
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -441,6 +441,25 @@ export default function SettingsPage() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card section-gap"
|
||||
style={{ borderStyle: 'dashed', borderColor: 'var(--border2)', background: 'var(--surface2)' }}
|
||||
>
|
||||
<div className="card-title" style={{ fontSize: 14 }}>
|
||||
Pilot: Visualisierungs-Module
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Testumgebung für die Widget-Schicht (Layer 3b). Die Übersicht und der Verlauf bleiben unverändert.
|
||||
</p>
|
||||
<Link
|
||||
to="/pilot/viz"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||
>
|
||||
Pilot öffnen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Auth actions */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">🔐 Konto</div>
|
||||
|
|
|
|||
20
frontend/src/pilot/widgetRegistry.js
Normal file
20
frontend/src/pilot/widgetRegistry.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import ReferenceValuesSummaryWidget from '../components/pilot/ReferenceValuesSummaryWidget'
|
||||
|
||||
/**
|
||||
* Pilot: konfigurierbare Widget-Reihenfolge (Layer 3b-Vorspiel).
|
||||
* Nur für /pilot/viz – produktives Dashboard bleibt unverändert.
|
||||
*/
|
||||
export const PILOT_WIDGET_ORDER = ['reference_values_summary']
|
||||
|
||||
export const PILOT_WIDGET_DEFS = {
|
||||
reference_values_summary: {
|
||||
id: 'reference_values_summary',
|
||||
title: 'Referenzwerte',
|
||||
description: 'Aktuellste Kennwerte pro Typ (Daten via Layer 1 → bestehende API).',
|
||||
Component: ReferenceValuesSummaryWidget,
|
||||
},
|
||||
}
|
||||
|
||||
export function getPilotWidgetsInOrder() {
|
||||
return PILOT_WIDGET_ORDER.map((id) => PILOT_WIDGET_DEFS[id]).filter(Boolean)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user