"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them. Every value coming from the API is escaped: patient free text never reaches these views, but the API is still treated as untrusted input. """ from __future__ import annotations from html import escape import streamlit as st from app.api.schemas import PatientListItem from dashboard import theme from dashboard.components import helpers as h def chip(text: str) -> str: return f'{escape(text)}' def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str: tip_attr = f' data-tip="{escape(tip)}"' if tip else "" cls = " mt-tip" if tip else "" lead = f'' if icon == "dot" else icon return f'{lead}{escape(text)}' def bar(fraction: float | None, tone: str) -> str: pct = 0 if fraction is None else max(0, min(100, round(fraction * 100))) return f'
' def page_header(title: str, subtitle: str = "", kicker: str = "") -> str: k = f'
{escape(kicker)}
' if kicker else "" s = f'
{subtitle}
' if subtitle else "" return f'
{k}

{escape(title)}

{s}
' def kpi_tiles(k: h.Kpis) -> str: tiles = [ ("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid), ("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid), ("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid), ("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid), ] body = "".join( f'
{escape(lbl)}
' f'
{val}
{escape(hint)}
' for lbl, val, hint, accent in tiles ) return f'
{body}
' LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"] def list_header() -> str: return '
' + "".join(f"
{c}
" for c in LIST_COLUMNS) + "
" def _score(value: int | None, trend: str) -> str: if value is None: return '—' t = h.trend_view(trend) return ( f'{value} ' f'' f"{t.text}" ) def patient_row(p: PatientListItem) -> str: """One row of the patient list; the grid matches `list_header()`.""" status = h.status_view(p.status_color) tone = theme.TONES[status.tone] chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules) adh = h.adherence_view(p.adherence_7d) risk = h.risk_view(p.discontinuation_risk) if p.open_alerts: count_style = f"background:{tone.tint};color:{tone.ink}" else: count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}" return ( '
' f'
{escape(p.display_name)}
' f'
{h.age_label(p.age)} · {chips}
' f'
{escape(p.drug or "—")}
день терапии {p.therapy_day}
' f"
{_score(p.phq9_last, p.trend)}
" f"
{_score(p.pcl5_last, p.trend)}
" f'
{adh.text}{bar(p.adherence_7d, adh.tone)}
' f"
{badge(risk.text, risk.tone, risk.hint)}
" f'
{escape(p.last_event or "—")}
' f'
{p.open_alerts}
' "
" ) def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str: lead = '' if pulse else "" return f'
{lead}
{html_body}
' def footer() -> None: st.html( '" )