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