diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py index 2709f3b..8dd1d69 100644 --- a/backend/routers/reference_values.py +++ b/backend/routers/reference_values.py @@ -117,6 +117,83 @@ def list_reference_value_meta_enums(session: dict = Depends(require_auth)): } +@router.get("/profile-reference-values/summary") +def profile_reference_values_summary( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Für das aktive Profil: je Referenztyp mit mindestens einem Eintrag der jüngste Wert + 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} + + @router.get("/profile-reference-values") def list_profile_reference_values( type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"), diff --git a/frontend/src/app.css b/frontend/src/app.css index 8d2e007..9f4d219 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -557,6 +557,61 @@ a.analysis-split__nav-item { width: 100%; } +/* Referenzwerte: Übersichtskacheln (responsive, bis 4 Spalten Desktop) */ +.ref-value-tiles-grid { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +@media (min-width: 520px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 900px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1200px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.ref-value-tile { + display: block; + width: 100%; + margin: 0; + padding: 14px 14px 12px; + text-align: left; + font-family: var(--font); + border-radius: 12px; + border: 1.5px solid var(--border2); + background: var(--surface); + color: var(--text1); + cursor: pointer; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.ref-value-tile:hover { + border-color: var(--accent); +} + +.ref-value-tile:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.ref-value-tile--active { + border-color: var(--accent); + background: var(--surface2); +} + /* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */ .admin-shell { width: 100%; diff --git a/frontend/src/pages/ProfileReferenceValuesPage.jsx b/frontend/src/pages/ProfileReferenceValuesPage.jsx index 93a85fc..fec0f40 100644 --- a/frontend/src/pages/ProfileReferenceValuesPage.jsx +++ b/frontend/src/pages/ProfileReferenceValuesPage.jsx @@ -1,5 +1,14 @@ import { useState, useEffect, useCallback } from 'react' -import { Gauge, Pencil, Trash2, Plus } from 'lucide-react' +import { + Gauge, + Pencil, + Trash2, + Plus, + TrendingUp, + TrendingDown, + Minus, + ArrowLeftRight, +} from 'lucide-react' import { api } from '../utils/api' import { labelSource, @@ -45,6 +54,55 @@ function buildValuePayload(selectedType, rawStr) { return { error: null, value_numeric: null, value_text: s } } +function formatNumericDelta(d, vdt) { + const v = (vdt || 'decimal').toLowerCase() + const sign = d > 0 ? '+' : '−' + let abs = Math.abs(d) + if (v === 'integer') { + return `${sign}${Math.round(abs)}` + } + let s = abs.toFixed(3) + s = s.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.$/, '') + return `${sign}${s}` +} + +/** Tendenz: jüngster vs. vorheriger Eintrag (gleiche Sortierung wie API). */ +function computeRefValueTrend(valueDataType, latest, previous) { + if (!latest || !previous) { + return { variant: 'none', label: null, Icon: null } + } + const vdt = (valueDataType || 'decimal').toLowerCase() + const numericTypes = ['integer', 'decimal', 'percentage'] + if (numericTypes.includes(vdt)) { + const a = latest.value_numeric != null ? Number(latest.value_numeric) : NaN + const b = previous.value_numeric != null ? Number(previous.value_numeric) : NaN + if (!Number.isFinite(a) || !Number.isFinite(b)) { + return { variant: 'unknown', label: 'Tendenz n/v', Icon: Minus } + } + const d = a - b + if (Math.abs(d) < 1e-9) { + return { variant: 'flat', label: 'gleich', Icon: Minus } + } + if (d > 0) { + return { variant: 'up', label: formatNumericDelta(d, vdt), Icon: TrendingUp } + } + return { variant: 'down', label: formatNumericDelta(d, vdt), Icon: TrendingDown } + } + const sa = formatEntryValue(latest) + const sb = formatEntryValue(previous) + if (sa === sb) { + return { variant: 'flat', label: 'unverändert', Icon: Minus } + } + return { variant: 'changed', label: 'geändert', Icon: ArrowLeftRight } +} + +function trendAccent(variant) { + if (variant === 'up') return 'var(--accent)' + if (variant === 'down') return 'var(--danger)' + if (variant === 'changed') return '#B45309' + return 'var(--text3)' +} + export default function ProfileReferenceValuesPage() { const [types, setTypes] = useState([]) const [metaEnums, setMetaEnums] = useState({ sources: [], methods: [], confidence_levels: [] }) @@ -52,6 +110,7 @@ export default function ProfileReferenceValuesPage() { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [listLoading, setListLoading] = useState(false) + const [summaryTiles, setSummaryTiles] = useState([]) const [error, setError] = useState(null) const [editingId, setEditingId] = useState(null) const [form, setForm] = useState({ @@ -66,11 +125,13 @@ export default function ProfileReferenceValuesPage() { const loadTypes = useCallback(async () => { try { setLoading(true) - const [data, enums] = await Promise.all([ + const [data, enums, summaryRes] = await Promise.all([ api.listReferenceValueTypes(), api.listReferenceValueMetaEnums(), + api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })), ]) setTypes(Array.isArray(data) ? data : []) + setSummaryTiles(Array.isArray(summaryRes?.tiles) ? summaryRes.tiles : []) setMetaEnums( enums && typeof enums === 'object' ? enums @@ -80,11 +141,21 @@ export default function ProfileReferenceValuesPage() { } catch (e) { setError(e.message || 'Typen konnten nicht geladen werden') setTypes([]) + setSummaryTiles([]) } finally { setLoading(false) } }, []) + const loadSummaryOnly = useCallback(async () => { + try { + const summaryRes = await api.listProfileReferenceValuesSummary() + setSummaryTiles(Array.isArray(summaryRes?.tiles) ? summaryRes.tiles : []) + } catch { + setSummaryTiles([]) + } + }, []) + useEffect(() => { loadTypes() }, [loadTypes]) @@ -125,9 +196,6 @@ export default function ProfileReferenceValuesPage() { }) } - const latestEntry = - !listLoading && entries.length > 0 ? entries[0] : null - const rules = selectedType?.validation_rules && typeof selectedType.validation_rules === 'object' ? selectedType.validation_rules : {} @@ -260,7 +328,7 @@ export default function ProfileReferenceValuesPage() { await api.createProfileReferenceValue(payload) } resetForm() - await loadEntries() + await Promise.all([loadEntries(), loadSummaryOnly()]) } catch (err) { setError(err.message || 'Speichern fehlgeschlagen') } @@ -284,7 +352,7 @@ export default function ProfileReferenceValuesPage() { setError(null) await api.deleteProfileReferenceValue(id) if (editingId === id) resetForm() - await loadEntries() + await Promise.all([loadEntries(), loadSummaryOnly()]) } catch (err) { setError(err.message || 'Löschen fehlgeschlagen') } @@ -332,6 +400,94 @@ export default function ProfileReferenceValuesPage() { ) : ( <> + {summaryTiles.filter((t) => t?.latest).length > 0 && ( +
+
Aktuelle Werte
+

+ Übersicht aller Kennwerte mit gespeicherten Einträgen – Kachel antippen, um den Typ unten zu wählen. + Tendenz bezieht sich auf den Vergleich mit dem vorherigen Eintrag. +

+
+ {summaryTiles + .filter((t) => t?.latest) + .map((tile) => { + const latest = tile.latest + const trend = computeRefValueTrend(tile.value_data_type, latest, tile.previous) + const TrendIcon = trend.Icon + const active = tile.type_key === selectedKey + return ( + + ) + })} +
+
+ )} +
Referenztyp
@@ -373,44 +529,6 @@ export default function ProfileReferenceValuesPage() { )}
- {latestEntry && selectedType && ( -
-
- Aktueller Wert -
-

- Stand {String(latestEntry.effective_date || '').slice(0, 10)} · zuletzt erfasst für{' '} - {selectedType.label} -

-
- {formatEntryValue(latestEntry)} - {latestEntry.unit ? ( - - {latestEntry.unit} - - ) : null} -
-

- {labelSource(latestEntry.source)} · {labelMethod(latestEntry.method)} ·{' '} - {labelConfidence(latestEntry.confidence)} -

-
- )} -
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 39b60bb..3cb7659 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -75,6 +75,7 @@ export const api = { listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'), listProfileReferenceValues: (typeKey) => req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`), + listProfileReferenceValuesSummary: () => req('/profile-reference-values/summary'), createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)), updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)), deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),