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 @@
+
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''
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 "