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
@@ -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()