feat(dashboard): минималистичная главная — утверждённый дизайн 02 #74

Merged
Eleboba merged 1 commits from feat/73-home-design into dev 2026-10-04 15:04:11 +03:00
8 changed files with 337 additions and 131 deletions
+5 -4
View File
@@ -92,6 +92,7 @@ def main() -> None:
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide") st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
st.html(theme.CSS) st.html(theme.CSS)
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
specs = discover_pages() specs = discover_pages()
nav: dict[str, list[st.Page]] = {} nav: dict[str, list[st.Page]] = {}
@@ -103,14 +104,14 @@ def main() -> None:
current = st.navigation(nav, position="sidebar", expanded=True) current = st.navigation(nav, position="sidebar", expanded=True)
with st.sidebar: with st.sidebar:
short, _ = state.doctor_names()
st.html( st.html(
'<div style="padding:4px 2px 10px"><div class="mt-kicker">MedITron · сопровождение</div>' '<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
f'<div style="font-weight:650;font-size:1rem;margin-top:2px">{short}</div>' "Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
'<div class="mt-meta">психиатр · кабинет МПК</div></div>'
) )
st.caption("Прототип на синтетических данных") st.caption("Прототип на синтетических данных")
short, _ = state.doctor_names()
st.html(ui.workspace_header(short, current.title))
live_alerts.flush_pending_toasts() live_alerts.flush_pending_toasts()
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario. # 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) live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="228" height="48" viewBox="0 0 228 48" role="img" aria-label="PTSRaiAgent — помощник врача">
<rect x="0" y="6" width="36" height="36" rx="11" fill="#285e4e"/>
<path d="M18 15v18M9 24h18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<g font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif">
<text x="47" y="25" fill="#213b38" font-size="21" font-weight="600" letter-spacing="-.7">PTSRaiAgent</text>
<text x="48" y="39" fill="#63736f" font-size="9" letter-spacing="1">ПОМОЩНИК ВРАЧА</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+2 -2
View File
@@ -106,12 +106,12 @@ def adherence_view(adherence: float | None) -> View:
def status_view(color: str, top_level: str | None = None) -> View: def status_view(color: str, top_level: str | None = None) -> View:
if color == "red": if color == "red":
return View("Кризис", "red", "острый сигнал — связаться сегодня") return View("Срочно", "red", "Кризисное уведомление")
if color == "yellow": if color == "yellow":
return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту") return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту")
if color == "grey": if color == "grey":
return View("Нет ответа", "grey", "молчание / только инфо-флаги") return View("Нет ответа", "grey", "молчание / только инфо-флаги")
return View("Стабильно", "green", "открытых сигналов нет") return View("Без уведомлений", "green", "открытых сигналов нет")
def level_tone(level: str) -> str: def level_tone(level: str) -> str:
+41 -24
View File
@@ -20,7 +20,7 @@ def chip(text: str) -> str:
def badge(text: str, tone: str, tip: str = "", icon: 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 "" cls = " mt-tip" if tip else ""
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>' return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
@@ -39,10 +39,10 @@ def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
def kpi_tiles(k: h.Kpis) -> str: def kpi_tiles(k: h.Kpis) -> str:
tiles = [ tiles = [
("На сопровождении", str(k.total), "пациентов в кабинете", theme.TONES["blue"].solid), ("Всего пациентов", str(k.total), "Под вашим наблюдением", theme.INK_2),
("🔴 Красная зона", str(k.red), "острый сигнал — связаться сегодня", theme.TONES["red"].solid), ("Срочно", str(k.red), "Кризисные уведомления", theme.TONES["red"].solid),
("🟡 Жёлтая зона", str(k.yellow), "врачу ≤ 24 ч или к визиту", theme.TONES["yellow"].solid), ("Требуют внимания", str(k.yellow + k.grey), f"Из них только инфо: {k.grey}", theme.TONES["yellow"].solid),
("Риск прерывания терапии", str(k.high_risk), "высокий индекс (правила, не ML)", theme.TONES["red"].solid), ("Без уведомлений", str(k.green), "Продолжают наблюдение", theme.TONES["green"].solid),
] ]
body = "".join( body = "".join(
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>' f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
@@ -52,7 +52,7 @@ def kpi_tiles(k: h.Kpis) -> str:
return f'<div class="mt-kpis">{body}</div>' return f'<div class="mt-kpis">{body}</div>'
LIST_COLUMNS = ["Пациент", "Терапия", "PHQ-9", "PCL-5", "Приём 7 дн.", "Риск прерывания", "Последнее событие", "Алерты"] LIST_COLUMNS = ["Пациент", "Статус", "Шкалы", "Приём 7 дн.", "Риск прерывания"]
def list_header() -> str: def list_header() -> str:
@@ -65,38 +65,55 @@ def _score(value: int | None, trend: str) -> str:
t = h.trend_view(trend) t = h.trend_view(trend)
return ( return (
f'<span class="mt-num">{value}</span> ' f'<span class="mt-num">{value}</span> '
f'<span class="mt-tip" data-tip="{escape(t.hint)}" style="color:{theme.TONES[t.tone].ink};font-weight:700">' f'<span title="{escape(t.hint)}" aria-label="{escape(t.hint)}" '
f'style="color:{theme.TONES[t.tone].ink};font-weight:600">'
f"{t.text}</span>" f"{t.text}</span>"
) )
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()`.""" """One row of the patient list; the grid matches `list_header()`."""
status = h.status_view(p.status_color) 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) chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
adh = h.adherence_view(p.adherence_7d) adh = h.adherence_view(p.adherence_7d)
risk = h.risk_view(p.discontinuation_risk) risk = h.risk_view(p.discontinuation_risk)
if p.open_alerts: details = ""
count_style = f"background:{tone.tint};color:{tone.ink}" if detailed:
else: details = (
count_style = f"background:{theme.TONES['grey'].tint};color:{theme.INK_3}" '<div class="mt-row-details">'
f'<div><span class="mt-field-label">Терапия</span>{escape(p.drug or "Нет назначения")} · {chips}</div>'
f'<div><span class="mt-field-label">Последнее событие</span>{escape(p.last_event or "Нет событий")}</div>'
'<div><span class="mt-field-label">Факторы риска прерывания</span>'
f"{escape(', '.join(p.discontinuation_risk.factors) or 'Значимых факторов нет')}</div></div>"
)
return ( return (
'<div class="mt-row">' '<div class="mt-row">'
f'<div><div class="mt-name"><span class="mt-dot mt-tip" data-tip="{escape(status.text + " — " + status.hint)}"' f'<div class="mt-person"><div class="mt-name">{escape(p.display_name)}</div>'
f' style="background:{tone.solid}"></span>{escape(p.display_name)}</div>' f'<div class="mt-meta">{h.age_label(p.age)} · день терапии {p.therapy_day}</div></div>'
f'<div class="mt-meta">{h.age_label(p.age)} · {chips}</div></div>' f'<div class="mt-status">{badge(status.text, status.tone, status.hint, icon="dot")}'
f'<div>{escape(p.drug or "—")}<div class="mt-meta">день терапии {p.therapy_day}</div></div>' f'<div class="mt-meta">Открытых уведомлений: {p.open_alerts}</div></div>'
f"<div>{_score(p.phq9_last, p.trend)}</div>" f'<div class="mt-scores"><div><span class="mt-scale-label">PHQ-9</span>{_score(p.phq9_last, p.trend)}</div>'
f"<div>{_score(p.pcl5_last, p.trend)}</div>" f'<div><span class="mt-scale-label">PCL-5</span>{_score(p.pcl5_last, p.trend)}</div></div>'
f'<div><span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>' '<div class="mt-adherence"><span class="mt-mobile-label">Приём за 7 дней</span>'
f"<div>{badge(risk.text, risk.tone, risk.hint)}</div>" f'<span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
f'<div class="mt-event">{escape(p.last_event or "—")}</div>' '<div class="mt-risk"><span class="mt-mobile-label">Риск прерывания</span>'
f'<div><span class="mt-count" style="{count_style}">{p.open_alerts}</span></div>' f"{badge(risk.text, risk.tone, risk.hint)}</div>"
"</div>" f"{details}</div>"
) )
def workspace_header(doctor: str, page: str) -> str:
return (
'<div class="mt-workspace"><div>Кабинет врача <span aria-hidden="true"> / </span>'
f' {escape(page)}</div><div class="mt-doctor"><span class="mt-doctor-icon" aria-hidden="true">+</span>'
f"<div><strong>{escape(doctor)}</strong><small>Врач-психиатр · демо</small></div></div></div>"
)
def empty_state(title: str, hint: str) -> str:
return f'<div class="mt-empty" role="status"><strong>{escape(title)}</strong><p>{escape(hint)}</p></div>'
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str: def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
lead = '<span class="mt-pulse"></span>' if pulse else "" lead = '<span class="mt-pulse"></span>' if pulse else ""
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>' return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
+68 -41
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from html import escape
import streamlit as st import streamlit as st
from dashboard.components import helpers as h from dashboard.components import helpers as h
@@ -10,20 +12,31 @@ from dashboard.components import patient_card, state, ui
NAV_ICON = ":material/groups:" NAV_ICON = ":material/groups:"
COLOR_FILTERS = { COLOR_FILTERS = {
"Все": None, "Все пациенты": None,
"🔴 Красные": "red", "Срочно": "red",
"🟡 Жёлтые": "yellow", "Внимание": "yellow",
"⚪ Нет ответа": "grey", "Нет ответа": "grey",
"🟢 Стабильные": "green", "Без уведомлений": "green",
} }
def _open(token: str) -> None: 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 st.query_params["patient"] = token
def _close() -> None: def _close() -> None:
st.query_params.pop("patient", 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: def render_card(token: str) -> None:
@@ -38,56 +51,70 @@ def render_list() -> None:
_, doctor_gen = state.doctor_names() _, doctor_gen = state.doctor_names()
today = state.today() today = state.today()
patients = state.call(lambda: state.client().patients()) patients = state.call(lambda: state.client().patients())
subtitle = "Пациенты на сопровождении между визитами — сначала те, кому нужно внимание сегодня" subtitle = "Всё важное о состоянии пациентов — в одном месте."
if today: if today:
subtitle += f" · данные на {h.short_date(today)}" subtitle += f" · данные на {h.short_date(today)}"
head, action = st.columns([4, 1.4], vertical_alignment="bottom") head, action = st.columns([4, 1.4], vertical_alignment="bottom")
with head: with head:
st.html(ui.page_header(f"Кабинет {doctor_gen}", subtitle, kicker="Красные зоны")) st.html(ui.page_header("Пациенты под наблюдением", subtitle, kicker="Рабочее пространство"))
with action: with action:
if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"): if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"):
st.switch_page(state.CONNECT_PAGE) st.switch_page(state.CONNECT_PAGE)
if patients is None: if patients is None:
st.button("Повторить загрузку", icon=":material/refresh:")
return return
items = h.sort_patients(patients) items = h.sort_patients(patients)
st.html(ui.kpi_tiles(h.kpis(items))) st.html(ui.kpi_tiles(h.kpis(items)))
st.html(
f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom") f'<div class="mt-section-head"><h2>Ваши пациенты <span>{len(items)}</span></h2>'
with f1: "<span>Сначала — пациенты с более высоким риском</span></div>"
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."
) )
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(
'<div class="mt-list-footer"><span role="status" aria-live="polite">'
f"Показано {len(shown)} из {len(items)} пациентов</span>"
f"<span>Кабинет {escape(doctor_gen)}</span></div>"
)
st.caption("Статус указан цветом и текстом. Риск прерывания терапии рассчитан по правилам; детали — в карточке.")
token = st.query_params.get("patient") token = st.query_params.get("patient")
+119 -57
View File
@@ -23,21 +23,21 @@ class ToneSpec:
TONES: dict[str, ToneSpec] = { TONES: dict[str, ToneSpec] = {
"red": ToneSpec("#D64545", "#A32D2D", "#FCEBEB", "🔴", "Красная зона"), "red": ToneSpec("#A83236", "#A83236", "#FAEEEE", "🔴", "Красная зона"),
"yellow": ToneSpec("#E0A800", "#7F5A00", "#FFF5D6", "🟡", "Жёлтая зона"), "yellow": ToneSpec("#A37524", "#805714", "#FBF3E4", "🟡", "Жёлтая зона"),
"green": ToneSpec("#2E9E5B", "#1D6E3F", "#E6F4EC", "🟢", "Стабильно"), "green": ToneSpec("#527F61", "#38634B", "#EEF4EE", "🟢", "Стабильно"),
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"), "grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""), "blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
} }
INK = "#1B1F24" INK = "#213B38"
INK_2 = "#4B535C" INK_2 = "#526660"
INK_3 = "#7A828C" INK_3 = "#63736F"
SURFACE = "#FFFFFF" SURFACE = "#FFFFFF"
CANVAS = "#F6F7F9" CANVAS = "#F6F7F3"
LINE = "#E3E6EA" LINE = "#E1E7E3"
SERIES = TONES["blue"].solid 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""" CSS = f"""
<style> <style>
@@ -49,57 +49,99 @@ CSS = f"""
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint}; --grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint}; --blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
}} }}
.stApp {{ background: var(--canvas); }} .stApp {{ background: var(--canvas); color: var(--ink); font-family: {FONT_STACK}; color-scheme: light; }}
.block-container {{ padding-top: 2.2rem; max-width: 1320px; }} .block-container {{ padding: 2rem 3rem 2.5rem; max-width: 1440px; }}
[data-testid="stHeader"] {{ background: var(--canvas); }}
[data-testid="stSidebar"] {{ background: var(--surface); border-right: 1px solid var(--line); }}
[data-testid="stSidebarNavLink"] {{ border-radius: 8px; color: var(--ink-2); }}
[data-testid="stSidebarNavLink"][aria-current="page"] {{ background: #edf3ee; color: #285e4e; }}
[data-testid="stSidebarNavLink"] span, [data-testid="stSidebarNav"] span {{ color: inherit; }}
[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] {{ color: var(--ink-2); }}
[data-testid="stWidgetLabel"], [data-testid="stCaptionContainer"] {{ color: var(--ink-2); }}
[data-testid="stTextInputRootElement"], [data-baseweb="select"] > div {{
background: var(--surface); border-color: var(--line); color: var(--ink); border-radius: 8px; }}
[data-testid="stApp"] input, [data-testid="stApp"] textarea {{ color: var(--ink); background: var(--surface); }}
[data-testid="stBaseButton-secondary"], [data-testid="stBaseButton-tertiary"] {{
background: var(--surface); color: var(--ink); border-color: var(--line); border-radius: 8px; }}
[data-testid="stBaseButton-primary"] {{ background: #285e4e; border-color: #285e4e; color: #fff; border-radius: 8px; }}
[data-testid="stBaseButton-primary"]:hover {{ background: #204b3e; border-color: #204b3e; color: #fff; }}
[data-testid="stBaseButton-secondary"]:hover {{ background: #edf3ee; border-color: #b4c6ba; color: #285e4e; }}
button:focus-visible, a:focus-visible, input:focus-visible, [tabindex="0"]:focus-visible {{
outline: 3px solid #598d7a !important; outline-offset: 3px; }}
.mt-sidebar-note {{ margin-top:36px; border-top:1px solid var(--line); padding-top:20px; color:var(--ink-3);
font-size:.78rem; line-height:1.6; }}
.mt-sidebar-note strong {{ display:block; font-weight:550; color:var(--ink); margin-bottom:5px; }}
.mt-workspace {{ display:flex; justify-content:space-between; align-items:center; gap:16px;
border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:20px; color:var(--ink-3); font-size:.8rem; }}
.mt-doctor {{ display:flex; gap:10px; align-items:center; }}
.mt-doctor strong {{ display:block; color:var(--ink); font-weight:550; font-size:.8rem; }}
.mt-doctor small {{ display:block; font-size:.7rem; }}
.mt-doctor-icon {{ width:34px; height:34px; background:#e7ede5; color:#285e4e; border-radius:50%;
display:grid; place-items:center; font-size:22px; }}
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }} h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
/* ---------- page header ---------- */ /* ---------- page header ---------- */
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; .mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
margin: 0 0 6px 0; }} margin: 10px 0 12px 0; }}
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }} .mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }} .mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
.mt-head h1 {{ font-size: 1.75rem; font-weight: 700; margin: 0; padding: 0; line-height: 1.2; }} .mt-head h1 {{ font-size: 2.15rem; font-weight: 550; margin: 0; padding: 0; line-height: 1.2; }}
.mt-sub {{ color: var(--ink-2); font-size: .95rem; margin-top: 4px; }} .mt-sub {{ color: var(--ink-2); font-size: .9rem; margin-top: 10px; }}
.mt-kicker {{ color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; .mt-kicker {{ color: var(--ink-3); font-size: .68rem; text-transform: uppercase; letter-spacing: .13em;
margin-bottom:8px;
font-weight: 600; }} font-weight: 600; }}
/* ---------- KPI tiles ---------- */ /* ---------- overview and patient list ---------- */
.mt-kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 14px 0 6px; }} .mt-kpis {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); background:var(--surface);
.mt-kpi {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; border:1px solid var(--line); border-radius:12px; padding:22px 0; margin:10px 0 18px; }}
position: relative; overflow: hidden; }} .mt-kpi {{ padding:0 24px; border-right:1px solid var(--line); min-width:0; }}
.mt-kpi::before {{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background: var(--kpi-accent, .mt-kpi:last-child {{ border-right:0; }}
var(--line)); }} .mt-kpi .lbl {{ color:var(--ink-2); font-size:.78rem; font-weight:500; }}
.mt-kpi .lbl {{ color: var(--ink-2); font-size: .85rem; font-weight: 500; }} .mt-kpi .lbl::before {{ content:""; display:inline-block; width:6px; height:6px; border-radius:50%;
.mt-kpi .val {{ color: var(--ink); font-size: 2rem; font-weight: 650; line-height: 1.15; margin-top: 4px; }} background:var(--kpi-accent,var(--line)); margin-right:7px; vertical-align:2px; }}
.mt-kpi .hint {{ color: var(--ink-3); font-size: .78rem; margin-top: 2px; }} .mt-kpi .val {{ color:var(--ink); font-size:2rem; font-weight:550; line-height:1.3; margin-top:8px;
font-variant-numeric:tabular-nums; }}
/* ---------- patient rows ---------- */ .mt-kpi .hint {{ color:var(--ink-3); font-size:.72rem; margin-top:3px; }}
.mt-cols, .mt-row {{ display:grid; align-items:center; gap: 14px; .mt-section-head {{ display:flex; justify-content:space-between; align-items:center; gap:16px; margin:14px 0 4px; }}
grid-template-columns: minmax(0,2fr) minmax(0,1.5fr) 58px 58px minmax(0,1fr) minmax(0,1.2fr) minmax(0,2fr) 40px; }} .mt-section-head h2 {{ font-size:1.25rem; font-weight:550; margin:0; padding:0; }}
.mt-row > div {{ min-width: 0; overflow-wrap: anywhere; }} .mt-section-head h2 span {{ background:#e6ece5; font-size:.75rem; padding:3px 7px; border-radius:5px;
.mt-cols {{ color: var(--ink-3); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; vertical-align:middle; margin-left:6px; }}
font-weight: 600; padding: 0 0 0 21px; margin-top: 8px; }} .mt-section-head > span {{ font-size:.72rem; color:var(--ink-3); }}
.mt-row {{ font-size: .9rem; color: var(--ink); }} .st-key-patient-list {{ background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:20px; }}
.mt-name {{ font-weight: 600; font-size: 1rem; display:flex; align-items:center; gap:8px; }} .st-key-patient-list [data-variant="segmented_control"] {{
.mt-meta {{ color: var(--ink-3); font-size: .8rem; margin-top: 3px; }} color:var(--ink-2); background:var(--surface); border-color:var(--line); font-size:.8rem; }}
.mt-dim {{ color: var(--ink-3); }} .st-key-patient-list [data-variant="segmented_control"][data-selected] {{
.mt-num {{ font-variant-numeric: tabular-nums; font-weight: 600; }} background:#edf3ee; color:#285e4e; border-color:#b4c6ba; font-weight:600; }}
.mt-event {{ color: var(--ink-2); font-size: .85rem; line-height: 1.3; }} .mt-cols, .mt-row {{ display:grid; align-items:center; gap:18px;
.mt-dot {{ width:10px; height:10px; border-radius:50%; display:inline-block; flex: none; }} grid-template-columns:minmax(0,1.8fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,.8fr) minmax(0,1.2fr); }}
div[class*="st-key-prow-"] {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; .mt-cols {{ color:var(--ink-3); font-size:.63rem; letter-spacing:.05em; text-transform:uppercase;
padding: 10px 12px 10px 16px; border-left-width: 5px; transition: box-shadow .15s ease; }} font-weight:550; padding:12px 0 4px; }}
div[class*="st-key-prow-"]:hover {{ box-shadow: 0 2px 10px rgba(20,30,40,.08); }} .mt-row {{ color:var(--ink); font-size:.82rem; }}
div[class*="st-key-prow-red"] {{ border-left-color: var(--red); background: linear-gradient(90deg, var(--red-tint) 0, .mt-row > div {{ min-width:0; overflow-wrap:anywhere; }}
var(--surface) 22%); }} .mt-name {{ font-weight:600; font-size:.92rem; }}
div[class*="st-key-prow-yellow"] {{ border-left-color: var(--yellow); }} .mt-meta {{ color:var(--ink-3); font-size:.7rem; margin-top:4px; }}
div[class*="st-key-prow-green"] {{ border-left-color: var(--green); }} .mt-num {{ font-variant-numeric:tabular-nums; font-weight:550; }}
div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }} .mt-dim {{ color:var(--ink-3); }}
.mt-dot {{ width:6px; height:6px; border-radius:50%; display:inline-block; flex:none; }}
.mt-scale-label {{ display:inline-block; min-width:42px; font-size:.67rem; color:var(--ink-3); margin-right:6px; }}
.mt-scores > div + div {{ margin-top:4px; }}
.mt-adherence .mt-bar {{ max-width:60px; height:3px; }}
.mt-mobile-label {{ display:none; }}
.mt-row-details {{ grid-column:1 / -1; display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;
font-size:.75rem; color:var(--ink-2); padding-top:12px; border-top:1px dashed var(--line); line-height:1.6; }}
.mt-field-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:4px; }}
div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4px; }}
.mt-list-footer {{ border-top:1px solid var(--line); padding-top:16px; margin-top:8px; display:flex;
justify-content:space-between; gap:12px; color:var(--ink-3); font-size:.72rem; }}
.mt-empty {{ text-align:center; padding:42px 20px; color:var(--ink-2); }}
.mt-empty strong {{ display:block; color:var(--ink); font-size:1rem; font-weight:550; }}
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */ /* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px; .mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }} background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.8rem; font-weight:600; padding: 3px 10px; .mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 999px; white-space: nowrap; }} border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }} .mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }} .mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }} .mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
@@ -110,7 +152,8 @@ div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center; .mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
justify-content:center; font-weight: 700; font-size: .85rem; }} justify-content:center; font-weight: 700; font-size: .85rem; }}
.mt-tip {{ position: relative; cursor: help; }} .mt-tip {{ position: relative; cursor: help; }}
.mt-tip:hover::after {{ content: attr(data-tip); position: absolute; z-index: 50; left: 0; top: 120%; .mt-tip:hover::after, .mt-tip:focus-visible::after {{ content: attr(data-tip); position: absolute;
z-index: 50; left: 0; top: 120%;
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff; min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px; font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,.18); }} box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
@@ -118,13 +161,11 @@ div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
/* ---------- banners & panels ---------- */ /* ---------- banners & panels ---------- */
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start; .mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
border: 1px solid; margin-bottom: 6px; }} border: 1px solid; margin-bottom: 6px; }}
.mt-banner.t-red {{ background: var(--red-tint); border-color: #F2C4C4; color: var(--red-ink); }} .mt-banner.t-red {{ background: #fffafa; border-color: #e7cbcb; border-left-width:3px; color: var(--red-ink); }}
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }} .mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
.mt-banner b {{ color: inherit; }} .mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none; .mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 0 rgba(214,69,69,.6); animation: mtpulse 1.6s infinite; }} box-shadow: 0 0 0 4px #faeeee; }}
@keyframes mtpulse {{ 0% {{ box-shadow: 0 0 0 0 rgba(214,69,69,.55); }} 70% {{ box-shadow: 0 0 0 10px
rgba(214,69,69,0); }} 100% {{ box-shadow: 0 0 0 0 rgba(214,69,69,0); }} }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650; .mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
color: var(--ink-3); margin: 2px 0 8px; }} color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }} .mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
@@ -160,10 +201,31 @@ div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px; .mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
border-top: 1px solid var(--line); padding-top: 12px; }} border-top: 1px solid var(--line); padding-top: 12px; }}
@media (max-width: 900px) {{ @media (max-width: 1100px) {{
.mt-kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }} .block-container {{ padding-left:1.5rem; padding-right:1.5rem; }}
.mt-cols {{ display: none; }} .mt-kpi {{ padding:0 16px; }}
.mt-row {{ grid-template-columns: 1fr 1fr; }} .mt-cols, .mt-row {{ gap:12px; }}
.mt-section-head > span {{ display:none; }}
}}
@media (max-width: 760px) {{
.block-container {{ padding:1.5rem 1rem; }}
.mt-workspace {{ flex-wrap:wrap; padding-bottom:18px; margin-bottom:10px; }}
.mt-head h1 {{ font-size:1.75rem; }}
.mt-kpis {{ grid-template-columns:repeat(2,minmax(0,1fr)); padding:0; }}
.mt-kpi {{ padding:18px; }}
.mt-kpi:nth-child(2) {{ border-right:0; }}
.mt-kpi:nth-child(-n+2) {{ border-bottom:1px solid var(--line); }}
.st-key-patient-list {{ padding:16px; }}
.mt-cols {{ display:none; }}
.mt-row {{ grid-template-columns:1fr 1fr; gap:16px; }}
.mt-person {{ grid-column:1 / -1; }}
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
*, *::before, *::after {{ animation:none !important; scroll-behavior:auto !important; transition:none !important; }}
}} }}
</style> </style>
""" """
+16 -1
View File
@@ -173,11 +173,26 @@ def test_snils_checksum():
def test_ui_row_escapes_and_shows_values(): def test_ui_row_escapes_and_shows_values():
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"}) p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
html = ui.patient_row(p) html = ui.patient_row(p, detailed=True)
assert "<script>" not in html and "&lt;script&gt;" in html assert "<script>" not in html and "&lt;script&gt;" in html
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
def test_home_fragments_escape_api_values():
payload = '<img src=x onerror="alert(1)">'
p = fixtures.PATIENTS[0].model_copy(
update={
"display_name": payload,
"drug": payload,
"last_event": payload,
"discontinuation_risk": _risk(70, "high", [payload]),
}
)
for html in (ui.patient_row(p), ui.patient_row(p, detailed=True), ui.workspace_header(payload, payload)):
assert "<img" not in html
assert "&lt;img" in html
def test_scale_figure_marks_unscheduled_and_bands(): def test_scale_figure_marks_unscheduled_and_bands():
card = fixtures.andrey_card() card = fixtures.andrey_card()
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date) fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
+78 -2
View File
@@ -23,12 +23,13 @@ class FakeApi:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str]] = [] self.calls: list[tuple[str, str]] = []
self.extra_alerts: list = [] self.extra_alerts: list = []
self.patients = list(fixtures.PATIENTS)
def __call__(self, req: httpx.Request) -> httpx.Response: def __call__(self, req: httpx.Request) -> httpx.Response:
path = req.url.path.removeprefix("/api/v1") path = req.url.path.removeprefix("/api/v1")
self.calls.append((req.method, path)) self.calls.append((req.method, path))
if path == "/doctors/doc-smirnova/patients": if path == "/doctors/doc-smirnova/patients":
return httpx.Response(200, json=_dump(fixtures.PATIENTS)) return httpx.Response(200, json=_dump(self.patients))
if path.startswith("/patients/tok-"): if path.startswith("/patients/tok-"):
card = fixtures.card(path.rsplit("/", 1)[1]) card = fixtures.card(path.rsplit("/", 1)[1])
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404) return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
@@ -64,6 +65,7 @@ def test_list_page_renders_sorted_patients(api):
assert not at.exception assert not at.exception
html = _html(at) html = _html(at)
assert "Кабинет д-ра Смирновой Е. А." in html assert "Кабинет д-ра Смирновой Е. А." in html
assert "Пациенты под наблюдением" in html
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.") assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
assert "Высокий · 70" in html # discontinuation-risk badge assert "Высокий · 70" in html # discontinuation-risk badge
open_buttons = [b for b in at.button if b.label == "Открыть"] open_buttons = [b for b in at.button if b.label == "Открыть"]
@@ -72,7 +74,9 @@ def test_list_page_renders_sorted_patients(api):
def test_list_filter_and_open_card(api): def test_list_filter_and_open_card(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run() at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("🔴 Красные").run() at.segmented_control(key="status_filter").set_value("Срочно").run()
at.text_input(key="patient_query").set_value("андр").run()
at.toggle(key="patient_details").set_value(True).run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1 assert len([b for b in at.button if b.label == "Открыть"]) == 1
at.button(key="open-tok-andrey").click().run() at.button(key="open-tok-andrey").click().run()
assert not at.exception assert not at.exception
@@ -81,6 +85,76 @@ def test_list_filter_and_open_card(api):
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
assert "Решения принимает врач" in html assert "Решения принимает врач" in html
assert ("GET", "/patients/tok-andrey") in api.calls assert ("GET", "/patients/tok-andrey") in api.calls
next(b for b in at.button if b.label == "Все пациенты").click().run()
assert not at.exception
assert not at.query_params.get("patient")
assert len([b for b in at.button if b.label == "Открыть"]) == 1 # the selected filter is retained
assert at.text_input(key="patient_query").value == "андр"
assert at.toggle(key="patient_details").value is True
@pytest.mark.parametrize("choice,expected", [("Внимание", 4), ("Без уведомлений", 3), ("Все пациенты", 8)])
def test_home_status_filters(api, choice, expected):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value(choice).run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == expected
assert f"Показано {expected} из 8 пациентов" in _html(at)
def test_home_search_empty_result_and_reset(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.text_input(key="patient_query").set_value(" ПАРОКСЕТИН ").run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Сергей П." in _html(at)
at.segmented_control(key="status_filter").set_value("Без уведомлений").run()
assert "Пациенты не найдены" in _html(at)
next(b for b in at.button if b.label == "Сбросить фильтры").click().run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 8
assert at.text_input(key="patient_query").value == ""
def test_home_empty_cabinet_keeps_connect_action(api):
api.patients = []
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "Пациентов пока нет" in _html(at)
assert "Показано 0 из 0 пациентов" in _html(at)
assert any(b.label == "Подключить пациента" for b in at.button)
assert not any(b.label == "Открыть" for b in at.button)
def test_home_details_preserve_clinical_information(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert "PHQ-9" in _html(at) and "PCL-5" in _html(at)
at.toggle(key="patient_details").set_value(True).run()
assert not at.exception
html = _html(at)
assert "сертралин 50 мг" in html
assert "PHQ-9 п.9 = 2 — кризисный протокол" in html
assert "пропуски 3 из 7" in html
def test_home_info_only_patient_and_missing_scores(api):
api.patients = [
fixtures.PATIENTS[0].model_copy(
update={
"status_color": "grey",
"top_alert_level": "info",
"phq9_last": None,
"pcl5_last": None,
"adherence_7d": None,
"open_alerts": 1,
}
)
]
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Нет ответа").run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Из них только инфо: 1" in _html(at)
assert 'mt-dim">—' in _html(at)
def test_card_ack_posts_and_marks_alert(api): def test_card_ack_posts_and_marks_alert(api):
@@ -103,6 +177,8 @@ def test_api_down_shows_banner(monkeypatch):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run() at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception assert not at.exception
assert "API недоступен" in _html(at) assert "API недоступен" in _html(at)
assert any(b.label == "Повторить загрузку" for b in at.button)
assert "Всего пациентов" not in _html(at) # failed loading is not a zero-patient cabinet
def test_connect_flow_shows_code(api): def test_connect_flow_shows_code(api):