feat: Add profile reference values summary endpoint and UI enhancements
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Introduced a new API endpoint for fetching a summary of profile reference values, providing the latest and previous entries for each reference type.
- Updated ProfileReferenceValuesPage to display summary tiles with trend indicators for better user insights.
- Enhanced CSS for responsive layout of reference value tiles, improving the overall user experience on different screen sizes.
- Implemented trend calculation logic to visually represent changes between the latest and previous reference values.
This commit is contained in:
Lars 2026-04-07 06:30:22 +02:00
parent c152721fe8
commit 3e916c082c
4 changed files with 296 additions and 45 deletions

View File

@ -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"),

View File

@ -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%;

View File

@ -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() {
</div>
) : (
<>
{summaryTiles.filter((t) => t?.latest).length > 0 && (
<div className="card section-gap">
<div className="card-title">Aktuelle Werte</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
Ü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.
</p>
<div className="ref-value-tiles-grid">
{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 (
<button
key={tile.type_key}
type="button"
className={'ref-value-tile' + (active ? ' ref-value-tile--active' : '')}
onClick={() => {
setSelectedKey(tile.type_key)
setEditingId(null)
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text2)',
marginBottom: 6,
lineHeight: 1.3,
}}
>
{tile.type_label}
</div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: 'var(--text1)',
lineHeight: 1.2,
letterSpacing: '-0.02em',
}}
>
{formatEntryValue(latest)}
{latest.unit ? (
<span
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text2)',
marginLeft: 6,
}}
>
{latest.unit}
</span>
) : null}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>
Stand {String(latest.effective_date || '').slice(0, 10)}
</div>
{trend.variant !== 'none' && TrendIcon ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginTop: 8,
fontSize: 12,
color: trendAccent(trend.variant),
fontWeight: 500,
}}
>
<TrendIcon size={15} strokeWidth={2.25} aria-hidden />
<span>
{trend.label}
<span style={{ color: 'var(--text3)', fontWeight: 400 }}> · ggü. Vorwert</span>
</span>
</div>
) : null}
</button>
)
})}
</div>
</div>
)}
<div className="card section-gap">
<div className="card-title">Referenztyp</div>
<div className="settings-page__field" style={{ borderBottom: 'none', paddingTop: 0 }}>
@ -373,44 +529,6 @@ export default function ProfileReferenceValuesPage() {
)}
</div>
{latestEntry && selectedType && (
<div
className="card section-gap"
style={{
borderLeft: '4px solid var(--accent)',
background: 'var(--surface)',
}}
>
<div className="card-title" style={{ marginBottom: 8 }}>
Aktueller Wert
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.4 }}>
Stand {String(latestEntry.effective_date || '').slice(0, 10)} · zuletzt erfasst für{' '}
<strong>{selectedType.label}</strong>
</p>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: 'var(--text1)',
lineHeight: 1.2,
letterSpacing: '-0.02em',
}}
>
{formatEntryValue(latestEntry)}
{latestEntry.unit ? (
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--text2)', marginLeft: 8 }}>
{latestEntry.unit}
</span>
) : null}
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '10px 0 0', lineHeight: 1.5 }}>
{labelSource(latestEntry.source)} · {labelMethod(latestEntry.method)} ·{' '}
{labelConfidence(latestEntry.confidence)}
</p>
</div>
)}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Plus size={16} />

View File

@ -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' }),