feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s

- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows,
  7-day adherence bars, discontinuation-risk badge with factor tooltip.
- Patient card: scales with severity bands from config/scales (unscheduled marked),
  mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack,
  safety plan, AI summary with disclaimer.
- Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on
  every page; full rerun on new alert only on doctor pages.
- Connect form -> POST /patients/connect -> code, QR, deep link.
- Thin httpx ApiClient over the frozen schemas with graceful "API недоступен".
- app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages
  of #17 plug in by adding a file.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:51:07 +03:00
co-authored by Claude Opus 5.5
parent 178cab5b1b
commit 2582c73d12
15 changed files with 2170 additions and 13 deletions
+97
View File
@@ -0,0 +1,97 @@
"""Doctor's patient list ("red zones", idea 2) and, with `?patient=<token>`, the patient card."""
from __future__ import annotations
import streamlit as st
from dashboard.components import helpers as h
from dashboard.components import patient_card, state, ui
NAV_ICON = ":material/groups:"
COLOR_FILTERS = {
"Все": None,
"🔴 Красные": "red",
"🟡 Жёлтые": "yellow",
"⚪ Нет ответа": "grey",
"🟢 Стабильные": "green",
}
def _open(token: str) -> None:
st.query_params["patient"] = token
def _close() -> None:
st.query_params.pop("patient", None)
def render_card(token: str) -> None:
st.button("Все пациенты", icon=":material/arrow_back:", type="tertiary", on_click=_close)
card = state.call(lambda: state.client().patient_card(token))
if card is None:
return
patient_card.render(card, state.today())
def render_list() -> None:
_, doctor_gen = state.doctor_names()
today = state.today()
patients = state.call(lambda: state.client().patients())
subtitle = "Пациенты на сопровождении между визитами — сначала те, кому нужно внимание сегодня"
if today:
subtitle += f" · данные на {h.short_date(today)}"
head, action = st.columns([4, 1.4], vertical_alignment="bottom")
with head:
st.html(ui.page_header(f"Кабинет {doctor_gen}", subtitle, kicker="Красные зоны"))
with action:
if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"):
st.switch_page(state.CONNECT_PAGE)
if patients is None:
return
items = h.sort_patients(patients)
st.html(ui.kpi_tiles(h.kpis(items)))
f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom")
with f1:
choice = st.segmented_control(
"Статус", list(COLOR_FILTERS), default="Все", key="status_filter", label_visibility="collapsed"
)
with f2:
query = st.text_input(
"Поиск", placeholder="Поиск по имени или препарату", key="patient_query", label_visibility="collapsed"
)
shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все"), query or "")
head_cols = st.columns([11, 1.5])
with head_cols[0]:
st.html(ui.list_header())
if not shown:
st.caption("Нет пациентов с выбранным статусом")
for p in shown:
with st.container(key=f"prow-{p.status_color}-{p.token}"):
row, btn = st.columns([11, 1.5], vertical_alignment="center")
with row:
st.html(ui.patient_row(p))
with btn:
st.button(
"Открыть",
key=f"open-{p.token}",
on_click=_open,
args=(p.token,),
type="primary" if p.status_color == "red" else "secondary",
width="stretch",
)
st.caption(
"Цвет — уровень открытых алертов (ADR-006): 🔴 кризис · 🟡 высокий/умеренный · ⚪ только инфо · 🟢 нет. "
"Риск прерывания — индекс правил (пропуски, побочки, намерение бросить, низкое настроение), не ML."
)
token = st.query_params.get("patient")
if token:
render_card(token)
else:
render_list()
@@ -0,0 +1,188 @@
"""«Подключить пациента»: after the in-person visit the doctor enters the prescription → one-time code + QR."""
from __future__ import annotations
import io
import os
from datetime import date, timedelta
from html import escape
import qrcode
import streamlit as st
from app.api.schemas import ConnectPatientIn, ConnectPatientOut
from dashboard.components import helpers as h
from dashboard.components import state, ui
NAV_ICON = ":material/person_add:"
TIME_OPTIONS = ["07:00", "08:00", "09:00", "10:00", "12:00", "14:00", "18:00", "20:00", "21:00", "22:00"]
MODULE_OPTIONS = ["veteran", "depression", "family"]
DEMO = {
"cp_name": "Андрей С.",
"cp_year": 1992,
"cp_sex": "м",
"cp_drug": "сертралин",
"cp_dose": "50 мг",
"cp_times": ["08:00"],
"cp_instr": "утром после еды",
"cp_modules": ["veteran", "depression"],
}
def qr_png(data: str) -> bytes:
img = qrcode.make(data, box_size=8, border=2)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _defaults(today: date) -> None:
ss = st.session_state
ss.setdefault("cp_name", "")
ss.setdefault("cp_year", 1990)
ss.setdefault("cp_sex", "м")
ss.setdefault("cp_snils", h.synthetic_snils())
ss.setdefault("cp_drug", "сертралин")
ss.setdefault("cp_times", ["08:00"])
ss.setdefault("cp_instr", "утром после еды")
ss.setdefault("cp_visit", today + timedelta(days=28))
ss.setdefault("cp_modules", ["veteran"])
def _fill_demo(today: date) -> None:
st.session_state.update(DEMO)
st.session_state["cp_snils"] = h.synthetic_snils()
st.session_state["cp_visit"] = today + timedelta(days=28)
def _new_snils() -> None:
st.session_state["cp_snils"] = h.synthetic_snils()
def _reset() -> None:
st.session_state.pop("cp_result", None)
for k in [*DEMO, "cp_snils", "cp_visit", "cp_consent"]:
st.session_state.pop(k, None)
def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True):
st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
"согласие и начнёт сопровождение. Код одноразовый.</div>"
)
c1, c2 = st.columns([1, 1.4], vertical_alignment="center")
with c1:
st.image(qr_png(link), width=220, caption="QR для подключения")
with c2:
st.html(f'<div class="mt-panel-title">Код подключения</div><div class="mt-code">{escape(res.code)}</div>')
st.link_button("Открыть ссылку", link, icon=":material/open_in_new:")
st.code(link, language=None)
b1, b2 = st.columns(2)
with b1:
if st.button("Карточка пациента", icon=":material/person:", width="stretch"):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": res.token})
with b2:
st.button("Подключить ещё", icon=":material/add:", on_click=_reset, width="stretch")
def main() -> None:
today = state.today() or date.today()
_defaults(today)
st.html(
ui.page_header(
"Подключить пациента",
"После очного приёма: назначение уходит в МИС (мок), пациент получает код и начинает сопровождение. "
"Только синтетические данные.",
kicker="Кабинет врача",
)
)
res: ConnectPatientOut | None = st.session_state.get("cp_result")
if res is not None:
_result(res, st.session_state.get("cp_result_name", ""))
return
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large")
with left, st.container(border=True):
st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2)
with a:
st.number_input("Год рождения", min_value=1940, max_value=today.year - 18, step=1, key="cp_year")
with b:
st.radio("Пол", ["м", "ж"], key="cp_sex", horizontal=True)
s1, s2 = st.columns([3, 1], vertical_alignment="bottom")
with s1:
st.text_input("СНИЛС (синтетический)", key="cp_snils")
with s2:
st.button("Новый", icon=":material/refresh:", on_click=_new_snils, width="stretch")
st.pills(
"Модули сопровождения",
MODULE_OPTIONS,
selection_mode="multi",
key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m],
)
with right, st.container(border=True):
st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1])
with d1:
drug = st.selectbox("Препарат (МНН)", list(h.DRUGS), key="cp_drug")
with d2:
doses = h.DRUGS[drug]
if st.session_state.get("cp_dose") not in doses:
st.session_state["cp_dose"] = doses[1] if len(doses) > 1 else doses[0]
st.selectbox("Доза", doses, key="cp_dose")
st.multiselect("Время приёма", TIME_OPTIONS, key="cp_times")
st.text_input("Указания", key="cp_instr")
st.date_input("Следующий визит", key="cp_visit", min_value=today, format="DD.MM.YYYY")
consent = st.checkbox(
"Пациент подписал информированное добровольное согласие на дистанционное наблюдение (323-ФЗ, ст. 20, 36.2)",
key="cp_consent",
)
if not st.button("Подключить и получить код", type="primary", icon=":material/qr_code_2:"):
return
ss = st.session_state
errors = []
if not ss["cp_name"].strip():
errors.append("укажите имя")
if not h.is_valid_snils(ss["cp_snils"]):
errors.append("СНИЛС не проходит контрольную сумму — нажмите «Новый»")
if not ss["cp_times"]:
errors.append("выберите время приёма")
if not ss["cp_modules"]:
errors.append("выберите хотя бы один модуль")
if not consent:
errors.append("нужно согласие пациента")
if errors:
st.error("Проверьте форму: " + "; ".join(errors))
return
body = ConnectPatientIn(
doctor_id=state.client().doctor_id,
display_name=ss["cp_name"].strip(),
birth_year=int(ss["cp_year"]),
sex="m" if ss["cp_sex"] == "м" else "f",
snils=ss["cp_snils"],
drug=ss["cp_drug"],
dose=ss["cp_dose"],
times=sorted(ss["cp_times"]),
instructions=ss["cp_instr"],
next_visit=ss["cp_visit"],
modules=ss["cp_modules"],
)
out = state.call(lambda: state.client().connect(body))
if out is not None:
ss["cp_result"] = out
ss["cp_result_name"] = body.display_name
st.rerun()
main()