feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
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:
@@ -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()
|
||||
Reference in New Issue
Block a user