127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
"""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'<span class="mt-chip">{escape(text)}</span>'
|
|
|
|
|
|
def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str:
|
|
tip_attr = f' data-tip="{escape(tip)}" title="{escape(tip)}" tabindex="0"' if tip else ""
|
|
cls = " mt-tip" if tip else ""
|
|
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
|
|
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
|
|
|
|
|
|
def bar(fraction: float | None, tone: str) -> str:
|
|
pct = 0 if fraction is None else max(0, min(100, round(fraction * 100)))
|
|
return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>'
|
|
|
|
|
|
def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
|
|
k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else ""
|
|
s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else ""
|
|
return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>'
|
|
|
|
|
|
def kpi_tiles(k: h.Kpis) -> str:
|
|
tiles = [
|
|
("Всего пациентов", str(k.total), "Под вашим наблюдением", theme.INK_2),
|
|
("Срочно", str(k.red), "Кризисные уведомления", theme.TONES["red"].solid),
|
|
("Требуют внимания", str(k.yellow + k.grey), f"Из них только инфо: {k.grey}", theme.TONES["yellow"].solid),
|
|
("Без уведомлений", str(k.green), "Продолжают наблюдение", theme.TONES["green"].solid),
|
|
]
|
|
body = "".join(
|
|
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
|
|
f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>'
|
|
for lbl, val, hint, accent in tiles
|
|
)
|
|
return f'<div class="mt-kpis">{body}</div>'
|
|
|
|
|
|
LIST_COLUMNS = ["Пациент", "Статус", "Шкалы", "Приём 7 дн.", "Риск прерывания"]
|
|
|
|
|
|
def list_header() -> str:
|
|
return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>"
|
|
|
|
|
|
def _score(value: int | None, trend: str) -> str:
|
|
if value is None:
|
|
return '<span class="mt-dim">—</span>'
|
|
t = h.trend_view(trend)
|
|
return (
|
|
f'<span class="mt-num">{value}</span> '
|
|
f'<span title="{escape(t.hint)}" aria-label="{escape(t.hint)}" '
|
|
f'style="color:{theme.TONES[t.tone].ink};font-weight:600">'
|
|
f"{t.text}</span>"
|
|
)
|
|
|
|
|
|
def patient_row(p: PatientListItem, *, detailed: bool = False) -> str:
|
|
"""One row of the patient list; the grid matches `list_header()`."""
|
|
status = h.status_view(p.status_color)
|
|
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)
|
|
details = ""
|
|
if detailed:
|
|
details = (
|
|
'<div class="mt-row-details">'
|
|
f'<div><span class="mt-field-label">Терапия</span>{escape(p.drug or "Нет назначения")} · {chips}</div>'
|
|
f'<div><span class="mt-field-label">Последнее событие</span>{escape(p.last_event or "Нет событий")}</div>'
|
|
'<div><span class="mt-field-label">Факторы риска прерывания</span>'
|
|
f"{escape(', '.join(p.discontinuation_risk.factors) or 'Значимых факторов нет')}</div></div>"
|
|
)
|
|
return (
|
|
'<div class="mt-row">'
|
|
f'<div class="mt-person"><div class="mt-name">{escape(p.display_name)}</div>'
|
|
f'<div class="mt-meta">{h.age_label(p.age)} · день терапии {p.therapy_day}</div></div>'
|
|
f'<div class="mt-status">{badge(status.text, status.tone, status.hint, icon="dot")}'
|
|
f'<div class="mt-meta">Открытых уведомлений: {p.open_alerts}</div></div>'
|
|
f'<div class="mt-scores"><div><span class="mt-scale-label">PHQ-9</span>{_score(p.phq9_last, p.trend)}</div>'
|
|
f'<div><span class="mt-scale-label">PCL-5</span>{_score(p.pcl5_last, p.trend)}</div></div>'
|
|
'<div class="mt-adherence"><span class="mt-mobile-label">Приём за 7 дней</span>'
|
|
f'<span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
|
|
'<div class="mt-risk"><span class="mt-mobile-label">Риск прерывания</span>'
|
|
f"{badge(risk.text, risk.tone, risk.hint)}</div>"
|
|
f"{details}</div>"
|
|
)
|
|
|
|
|
|
def workspace_header(doctor: str, page: str) -> str:
|
|
return (
|
|
'<div class="mt-workspace"><div>Кабинет врача <span aria-hidden="true"> / </span>'
|
|
f' {escape(page)}</div><div class="mt-doctor"><span class="mt-doctor-icon" aria-hidden="true">+</span>'
|
|
f"<div><strong>{escape(doctor)}</strong><small>Врач-психиатр · демо</small></div></div></div>"
|
|
)
|
|
|
|
|
|
def empty_state(title: str, hint: str) -> str:
|
|
return f'<div class="mt-empty" role="status"><strong>{escape(title)}</strong><p>{escape(hint)}</p></div>'
|
|
|
|
|
|
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
|
|
lead = '<span class="mt-pulse"></span>' if pulse else ""
|
|
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
|
|
|
|
|
|
def footer() -> None:
|
|
st.html(
|
|
'<div class="mt-footer">Прототип на синтетических данных · MedITron · '
|
|
"ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>"
|
|
)
|