diff --git a/dashboard/app.py b/dashboard/app.py index 7eb34bc..d7da06e 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -92,6 +92,7 @@ def main() -> None: st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide") st.html(theme.CSS) + st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large") specs = discover_pages() nav: dict[str, list[st.Page]] = {} @@ -103,14 +104,14 @@ def main() -> None: current = st.navigation(nav, position="sidebar", expanded=True) with st.sidebar: - short, _ = state.doctor_names() st.html( - '
MedITron · сопровождение
' - f'
{short}
' - '
психиатр · кабинет МПК
' + '
Рядом между визитами' + "Наблюдение за состоянием пациента и поддержка назначенного лечения.
" ) st.caption("Прототип на синтетических данных") + short, _ = state.doctor_names() + st.html(ui.workspace_header(short, current.title)) live_alerts.flush_pending_toasts() # Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario. live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION) diff --git a/dashboard/assets/logo.svg b/dashboard/assets/logo.svg new file mode 100644 index 0000000..445b743 --- /dev/null +++ b/dashboard/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + PTSRaiAgent + ПОМОЩНИК ВРАЧА + + diff --git a/dashboard/components/helpers.py b/dashboard/components/helpers.py index 64c2780..e6d5931 100644 --- a/dashboard/components/helpers.py +++ b/dashboard/components/helpers.py @@ -106,12 +106,12 @@ def adherence_view(adherence: float | None) -> View: def status_view(color: str, top_level: str | None = None) -> View: if color == "red": - return View("Кризис", "red", "острый сигнал — связаться сегодня") + return View("Срочно", "red", "Кризисное уведомление") if color == "yellow": return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту") if color == "grey": return View("Нет ответа", "grey", "молчание / только инфо-флаги") - return View("Стабильно", "green", "открытых сигналов нет") + return View("Без уведомлений", "green", "открытых сигналов нет") def level_tone(level: str) -> str: diff --git a/dashboard/components/ui.py b/dashboard/components/ui.py index e7884b5..505c7da 100644 --- a/dashboard/components/ui.py +++ b/dashboard/components/ui.py @@ -20,7 +20,7 @@ def chip(text: str) -> str: def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str: - tip_attr = f' data-tip="{escape(tip)}"' if tip else "" + tip_attr = f' data-tip="{escape(tip)}" title="{escape(tip)}" tabindex="0"' if tip else "" cls = " mt-tip" if tip else "" lead = f'' if icon == "dot" else icon return f'{lead}{escape(text)}' @@ -39,10 +39,10 @@ def page_header(title: str, subtitle: str = "", kicker: str = "") -> str: def kpi_tiles(k: h.Kpis) -> str: tiles = [ - ("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid), - ("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid), - ("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid), - ("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid), + ("Всего пациентов", 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'
{escape(lbl)}
' @@ -52,7 +52,7 @@ def kpi_tiles(k: h.Kpis) -> str: return f'
{body}
' -LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"] +LIST_COLUMNS = ["Пациент", "Статус", "Шкалы", "Приём 7 дн.", "Риск прерывания"] def list_header() -> str: @@ -65,38 +65,55 @@ def _score(value: int | None, trend: str) -> str: t = h.trend_view(trend) return ( f'{value} ' - f'' + f'' f"{t.text}" ) -def patient_row(p: PatientListItem) -> str: +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) - tone = theme.TONES[status.tone] 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) - if p.open_alerts: - count_style = f"background:{tone.tint};color:{tone.ink}" - else: - count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}" + details = "" + if detailed: + details = ( + '
' + f'
Терапия{escape(p.drug or "Нет назначения")} · {chips}
' + f'
Последнее событие{escape(p.last_event or "Нет событий")}
' + '
Факторы риска прерывания' + f"{escape(', '.join(p.discontinuation_risk.factors) or 'Значимых факторов нет')}
" + ) return ( '
' - f'
{escape(p.display_name)}
' - f'
{h.age_label(p.age)} · {chips}
' - f'
{escape(p.drug or "—")}
день терапии {p.therapy_day}
' - f"
{_score(p.phq9_last, p.trend)}
" - f"
{_score(p.pcl5_last, p.trend)}
" - f'
{adh.text}{bar(p.adherence_7d, adh.tone)}
' - f"
{badge(risk.text, risk.tone, risk.hint)}
" - f'
{escape(p.last_event or "—")}
' - f'
{p.open_alerts}
' - "
" + f'
{escape(p.display_name)}
' + f'
{h.age_label(p.age)} · день терапии {p.therapy_day}
' + f'
{badge(status.text, status.tone, status.hint, icon="dot")}' + f'
Открытых уведомлений: {p.open_alerts}
' + f'
PHQ-9{_score(p.phq9_last, p.trend)}
' + f'
PCL-5{_score(p.pcl5_last, p.trend)}
' + '
Приём за 7 дней' + f'{adh.text}{bar(p.adherence_7d, adh.tone)}
' + '
Риск прерывания' + f"{badge(risk.text, risk.tone, risk.hint)}
" + f"{details}
" ) +def workspace_header(doctor: str, page: str) -> str: + return ( + '
Кабинет врача ' + f' {escape(page)}
' + f"
{escape(doctor)}Врач-психиатр · демо
" + ) + + +def empty_state(title: str, hint: str) -> str: + return f'
{escape(title)}

{escape(hint)}

' + + def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str: lead = '' if pulse else "" return f'
{lead}
{html_body}
' diff --git a/dashboard/pages/1_Пациенты.py b/dashboard/pages/1_Пациенты.py index 90f9da6..10c3a0f 100644 --- a/dashboard/pages/1_Пациенты.py +++ b/dashboard/pages/1_Пациенты.py @@ -2,6 +2,8 @@ from __future__ import annotations +from html import escape + import streamlit as st from dashboard.components import helpers as h @@ -10,20 +12,31 @@ from dashboard.components import patient_card, state, ui NAV_ICON = ":material/groups:" COLOR_FILTERS = { - "Все": None, - "🔴 Красные": "red", - "🟡 Жёлтые": "yellow", - "⚪ Нет ответа": "grey", - "🟢 Стабильные": "green", + "Все пациенты": None, + "Срочно": "red", + "Внимание": "yellow", + "Нет ответа": "grey", + "Без уведомлений": "green", } def _open(token: str) -> None: + st.session_state["patient_list_state"] = { + key: st.session_state[key] + for key in ("status_filter", "patient_query", "patient_details") + if key in st.session_state + } st.query_params["patient"] = token def _close() -> None: st.query_params.pop("patient", None) + st.session_state.update(st.session_state.pop("patient_list_state", {})) + + +def _reset_filters() -> None: + st.session_state["status_filter"] = "Все пациенты" + st.session_state["patient_query"] = "" def render_card(token: str) -> None: @@ -38,56 +51,70 @@ def render_list() -> None: _, doctor_gen = state.doctor_names() today = state.today() patients = state.call(lambda: state.client().patients()) - subtitle = "Пациенты на сопровождении между визитами — сначала те, кому нужно внимание сегодня" + 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="Красные зоны")) + st.html(ui.page_header("Пациенты под наблюдением", 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: + st.button("Повторить загрузку", icon=":material/refresh:") 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." + st.html( + f'

Ваши пациенты {len(items)}

' + "Сначала — пациенты с более высоким риском
" ) + with st.container(key="patient-list"): + f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom") + with f1: + st.session_state.setdefault("status_filter", "Все пациенты") + choice = st.segmented_control( + "Статус", list(COLOR_FILTERS), key="status_filter", label_visibility="collapsed" + ) + with f2: + query = st.text_input( + "Поиск пациента", placeholder="Имя или препарат…", key="patient_query", label_visibility="collapsed" + ) + detailed = st.toggle( + "Подробный список", key="patient_details", help="Назначение, последнее событие и факторы риска" + ) + shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все пациенты"), query or "") + if not items: + st.html(ui.empty_state("Пациентов пока нет", "Подключите первого пациента кнопкой вверху страницы.")) + elif not shown: + st.html(ui.empty_state("Пациенты не найдены", "Измените имя, препарат или выбранный статус.")) + st.button("Сбросить фильтры", on_click=_reset_filters) + else: + head_cols = st.columns([11, 1.4]) + with head_cols[0]: + st.html(ui.list_header()) + for p in shown: + with st.container(key=f"prow-{p.status_color}-{p.token}"): + row, btn = st.columns([11, 1.4], vertical_alignment="center") + with row: + st.html(ui.patient_row(p, detailed=detailed)) + with btn: + st.button( + "Открыть", + key=f"open-{p.token}", + on_click=_open, + args=(p.token,), + help=f"Открыть карточку: {p.display_name}", + width="stretch", + ) + st.html( + '" + ) + st.caption("Статус указан цветом и текстом. Риск прерывания терапии рассчитан по правилам; детали — в карточке.") token = st.query_params.get("patient") diff --git a/dashboard/theme.py b/dashboard/theme.py index 7283d0d..efc4aa9 100644 --- a/dashboard/theme.py +++ b/dashboard/theme.py @@ -23,21 +23,21 @@ class ToneSpec: TONES: dict[str, ToneSpec] = { - "red": ToneSpec("#D64545", "#A32D2D", "#FCEBEB", "🔴", "Красная зона"), - "yellow": ToneSpec("#E0A800", "#7F5A00", "#FFF5D6", "🟡", "Жёлтая зона"), - "green": ToneSpec("#2E9E5B", "#1D6E3F", "#E6F4EC", "🟢", "Стабильно"), + "red": ToneSpec("#A83236", "#A83236", "#FAEEEE", "🔴", "Красная зона"), + "yellow": ToneSpec("#A37524", "#805714", "#FBF3E4", "🟡", "Жёлтая зона"), + "green": ToneSpec("#527F61", "#38634B", "#EEF4EE", "🟢", "Стабильно"), "grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"), "blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""), } -INK = "#1B1F24" -INK_2 = "#4B535C" -INK_3 = "#7A828C" +INK = "#213B38" +INK_2 = "#526660" +INK_3 = "#63736F" SURFACE = "#FFFFFF" -CANVAS = "#F6F7F9" -LINE = "#E3E6EA" +CANVAS = "#F6F7F3" +LINE = "#E1E7E3" SERIES = TONES["blue"].solid -FONT_STACK = "'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif" +FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" CSS = f""" """ diff --git a/tests/test_dashboard_helpers.py b/tests/test_dashboard_helpers.py index 0580f17..afc75a1 100644 --- a/tests/test_dashboard_helpers.py +++ b/tests/test_dashboard_helpers.py @@ -173,11 +173,26 @@ def test_snils_checksum(): def test_ui_row_escapes_and_shows_values(): p = fixtures.PATIENTS[0].model_copy(update={"last_event": ""}) - html = ui.patient_row(p) + html = ui.patient_row(p, detailed=True) assert "