feat: Add profile reference values summary endpoint and UI enhancements
- 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:
parent
c152721fe8
commit
3e916c082c
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user