Merge pull request 'feat(dashboard): минималистичная главная — утверждённый дизайн 02' (#74) from feat/73-home-design into dev
CI / ruff · pytest (push) Successful in 1m58s
CI / ruff · pytest (push) Successful in 1m58s
Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
+5
-4
@@ -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(
|
||||
'<div style="padding:4px 2px 10px"><div class="mt-kicker">MedITron · сопровождение</div>'
|
||||
f'<div style="font-weight:650;font-size:1rem;margin-top:2px">{short}</div>'
|
||||
'<div class="mt-meta">психиатр · кабинет МПК</div></div>'
|
||||
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
|
||||
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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 |
@@ -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:
|
||||
|
||||
+41
-24
@@ -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'<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>'
|
||||
@@ -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'<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>'
|
||||
|
||||
|
||||
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'<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>"
|
||||
)
|
||||
|
||||
|
||||
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 = (
|
||||
'<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 (
|
||||
'<div class="mt-row">'
|
||||
f'<div><div class="mt-name"><span class="mt-dot mt-tip" data-tip="{escape(status.text + " — " + status.hint)}"'
|
||||
f' style="background:{tone.solid}"></span>{escape(p.display_name)}</div>'
|
||||
f'<div class="mt-meta">{h.age_label(p.age)} · {chips}</div></div>'
|
||||
f'<div>{escape(p.drug or "—")}<div class="mt-meta">день терапии {p.therapy_day}</div></div>'
|
||||
f"<div>{_score(p.phq9_last, p.trend)}</div>"
|
||||
f"<div>{_score(p.pcl5_last, p.trend)}</div>"
|
||||
f'<div><span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
|
||||
f"<div>{badge(risk.text, risk.tone, risk.hint)}</div>"
|
||||
f'<div class="mt-event">{escape(p.last_event or "—")}</div>'
|
||||
f'<div><span class="mt-count" style="{count_style}">{p.open_alerts}</span></div>'
|
||||
"</div>"
|
||||
f'<div class="mt-person"><div class="mt-name">{escape(p.display_name)}</div>'
|
||||
f'<div class="mt-meta">{h.age_label(p.age)} · день терапии {p.therapy_day}</div></div>'
|
||||
f'<div class="mt-status">{badge(status.text, status.tone, status.hint, icon="dot")}'
|
||||
f'<div class="mt-meta">Открытых уведомлений: {p.open_alerts}</div></div>'
|
||||
f'<div class="mt-scores"><div><span class="mt-scale-label">PHQ-9</span>{_score(p.phq9_last, p.trend)}</div>'
|
||||
f'<div><span class="mt-scale-label">PCL-5</span>{_score(p.pcl5_last, p.trend)}</div></div>'
|
||||
'<div class="mt-adherence"><span class="mt-mobile-label">Приём за 7 дней</span>'
|
||||
f'<span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
|
||||
'<div class="mt-risk"><span class="mt-mobile-label">Риск прерывания</span>'
|
||||
f"{badge(risk.text, risk.tone, risk.hint)}</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:
|
||||
lead = '<span class="mt-pulse"></span>' if pulse else ""
|
||||
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
|
||||
|
||||
@@ -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)))
|
||||
|
||||
st.html(
|
||||
f'<div class="mt-section-head"><h2>Ваши пациенты <span>{len(items)}</span></h2>'
|
||||
"<span>Сначала — пациенты с более высоким риском</span></div>"
|
||||
)
|
||||
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), default="Все", key="status_filter", label_visibility="collapsed"
|
||||
"Статус", list(COLOR_FILTERS), key="status_filter", label_visibility="collapsed"
|
||||
)
|
||||
with f2:
|
||||
query = st.text_input(
|
||||
"Поиск", placeholder="Поиск по имени или препарату", key="patient_query", label_visibility="collapsed"
|
||||
"Поиск пациента", 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])
|
||||
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())
|
||||
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")
|
||||
row, btn = st.columns([11, 1.4], vertical_alignment="center")
|
||||
with row:
|
||||
st.html(ui.patient_row(p))
|
||||
st.html(ui.patient_row(p, detailed=detailed))
|
||||
with btn:
|
||||
st.button(
|
||||
"Открыть",
|
||||
key=f"open-{p.token}",
|
||||
on_click=_open,
|
||||
args=(p.token,),
|
||||
type="primary" if p.status_color == "red" else "secondary",
|
||||
help=f"Открыть карточку: {p.display_name}",
|
||||
width="stretch",
|
||||
)
|
||||
st.caption(
|
||||
"Цвет — уровень открытых алертов (ADR-006): 🔴 кризис · 🟡 высокий/умеренный · ⚪ только инфо · 🟢 нет. "
|
||||
"Риск прерывания — индекс правил (пропуски, побочки, намерение бросить, низкое настроение), не ML."
|
||||
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")
|
||||
|
||||
+119
-57
@@ -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"""
|
||||
<style>
|
||||
@@ -49,57 +49,99 @@ CSS = f"""
|
||||
--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};
|
||||
}}
|
||||
.stApp {{ background: var(--canvas); }}
|
||||
.block-container {{ padding-top: 2.2rem; max-width: 1320px; }}
|
||||
.stApp {{ background: var(--canvas); color: var(--ink); font-family: {FONT_STACK}; color-scheme: light; }}
|
||||
.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; }}
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
.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 .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-sub {{ color: var(--ink-2); font-size: .95rem; margin-top: 4px; }}
|
||||
.mt-kicker {{ color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
.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: .9rem; margin-top: 10px; }}
|
||||
.mt-kicker {{ color: var(--ink-3); font-size: .68rem; text-transform: uppercase; letter-spacing: .13em;
|
||||
margin-bottom:8px;
|
||||
font-weight: 600; }}
|
||||
|
||||
/* ---------- KPI tiles ---------- */
|
||||
.mt-kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 12px; margin: 14px 0 6px; }}
|
||||
.mt-kpi {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
|
||||
position: relative; overflow: hidden; }}
|
||||
.mt-kpi::before {{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background: var(--kpi-accent,
|
||||
var(--line)); }}
|
||||
.mt-kpi .lbl {{ color: var(--ink-2); font-size: .85rem; font-weight: 500; }}
|
||||
.mt-kpi .val {{ color: var(--ink); font-size: 2rem; font-weight: 650; line-height: 1.15; margin-top: 4px; }}
|
||||
.mt-kpi .hint {{ color: var(--ink-3); font-size: .78rem; margin-top: 2px; }}
|
||||
|
||||
/* ---------- patient rows ---------- */
|
||||
.mt-cols, .mt-row {{ display:grid; align-items:center; gap: 14px;
|
||||
grid-template-columns: minmax(0,2fr) minmax(0,1.5fr) 58px 58px minmax(0,1fr) minmax(0,1.2fr) minmax(0,2fr) 40px; }}
|
||||
.mt-row > div {{ min-width: 0; overflow-wrap: anywhere; }}
|
||||
.mt-cols {{ color: var(--ink-3); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
font-weight: 600; padding: 0 0 0 21px; margin-top: 8px; }}
|
||||
.mt-row {{ font-size: .9rem; color: var(--ink); }}
|
||||
.mt-name {{ font-weight: 600; font-size: 1rem; display:flex; align-items:center; gap:8px; }}
|
||||
.mt-meta {{ color: var(--ink-3); font-size: .8rem; margin-top: 3px; }}
|
||||
.mt-dim {{ color: var(--ink-3); }}
|
||||
.mt-num {{ font-variant-numeric: tabular-nums; font-weight: 600; }}
|
||||
.mt-event {{ color: var(--ink-2); font-size: .85rem; line-height: 1.3; }}
|
||||
.mt-dot {{ width:10px; height:10px; border-radius:50%; display:inline-block; flex: none; }}
|
||||
div[class*="st-key-prow-"] {{ background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 10px 12px 10px 16px; border-left-width: 5px; transition: box-shadow .15s ease; }}
|
||||
div[class*="st-key-prow-"]:hover {{ box-shadow: 0 2px 10px rgba(20,30,40,.08); }}
|
||||
div[class*="st-key-prow-red"] {{ border-left-color: var(--red); background: linear-gradient(90deg, var(--red-tint) 0,
|
||||
var(--surface) 22%); }}
|
||||
div[class*="st-key-prow-yellow"] {{ border-left-color: var(--yellow); }}
|
||||
div[class*="st-key-prow-green"] {{ border-left-color: var(--green); }}
|
||||
div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
|
||||
/* ---------- overview and patient list ---------- */
|
||||
.mt-kpis {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); background:var(--surface);
|
||||
border:1px solid var(--line); border-radius:12px; padding:22px 0; margin:10px 0 18px; }}
|
||||
.mt-kpi {{ padding:0 24px; border-right:1px solid var(--line); min-width:0; }}
|
||||
.mt-kpi:last-child {{ border-right:0; }}
|
||||
.mt-kpi .lbl {{ color:var(--ink-2); font-size:.78rem; font-weight:500; }}
|
||||
.mt-kpi .lbl::before {{ content:""; display:inline-block; width:6px; height:6px; border-radius:50%;
|
||||
background:var(--kpi-accent,var(--line)); margin-right:7px; vertical-align: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; }}
|
||||
.mt-kpi .hint {{ color:var(--ink-3); font-size:.72rem; margin-top:3px; }}
|
||||
.mt-section-head {{ display:flex; justify-content:space-between; align-items:center; gap:16px; margin:14px 0 4px; }}
|
||||
.mt-section-head h2 {{ font-size:1.25rem; font-weight:550; margin:0; padding:0; }}
|
||||
.mt-section-head h2 span {{ background:#e6ece5; font-size:.75rem; padding:3px 7px; border-radius:5px;
|
||||
vertical-align:middle; margin-left:6px; }}
|
||||
.mt-section-head > span {{ font-size:.72rem; color:var(--ink-3); }}
|
||||
.st-key-patient-list {{ background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:20px; }}
|
||||
.st-key-patient-list [data-variant="segmented_control"] {{
|
||||
color:var(--ink-2); background:var(--surface); border-color:var(--line); font-size:.8rem; }}
|
||||
.st-key-patient-list [data-variant="segmented_control"][data-selected] {{
|
||||
background:#edf3ee; color:#285e4e; border-color:#b4c6ba; font-weight:600; }}
|
||||
.mt-cols, .mt-row {{ display:grid; align-items:center; gap:18px;
|
||||
grid-template-columns:minmax(0,1.8fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,.8fr) minmax(0,1.2fr); }}
|
||||
.mt-cols {{ color:var(--ink-3); font-size:.63rem; letter-spacing:.05em; text-transform:uppercase;
|
||||
font-weight:550; padding:12px 0 4px; }}
|
||||
.mt-row {{ color:var(--ink); font-size:.82rem; }}
|
||||
.mt-row > div {{ min-width:0; overflow-wrap:anywhere; }}
|
||||
.mt-name {{ font-weight:600; font-size:.92rem; }}
|
||||
.mt-meta {{ color:var(--ink-3); font-size:.7rem; margin-top:4px; }}
|
||||
.mt-num {{ font-variant-numeric:tabular-nums; font-weight:550; }}
|
||||
.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 ---------- */
|
||||
.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; }}
|
||||
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.8rem; font-weight:600; padding: 3px 10px;
|
||||
border-radius: 999px; white-space: nowrap; }}
|
||||
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
|
||||
border-radius: 5px; white-space: normal; }}
|
||||
.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-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;
|
||||
justify-content:center; font-weight: 700; font-size: .85rem; }}
|
||||
.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;
|
||||
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); }}
|
||||
@@ -118,13 +161,11 @@ div[class*="st-key-prow-grey"] {{ border-left-color: var(--grey); }}
|
||||
/* ---------- banners & panels ---------- */
|
||||
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
|
||||
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 b {{ color: inherit; }}
|
||||
.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; }}
|
||||
@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); }} }}
|
||||
box-shadow: 0 0 0 4px #faeeee; }}
|
||||
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
|
||||
color: var(--ink-3); margin: 2px 0 8px; }}
|
||||
.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;
|
||||
border-top: 1px solid var(--line); padding-top: 12px; }}
|
||||
|
||||
@media (max-width: 900px) {{
|
||||
.mt-kpis {{ grid-template-columns: repeat(2, minmax(0,1fr)); }}
|
||||
.mt-cols {{ display: none; }}
|
||||
.mt-row {{ grid-template-columns: 1fr 1fr; }}
|
||||
@media (max-width: 1100px) {{
|
||||
.block-container {{ padding-left:1.5rem; padding-right:1.5rem; }}
|
||||
.mt-kpi {{ padding:0 16px; }}
|
||||
.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>
|
||||
"""
|
||||
|
||||
@@ -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": "<script>x</script>"})
|
||||
html = ui.patient_row(p)
|
||||
html = ui.patient_row(p, detailed=True)
|
||||
assert "<script>" not in html and "<script>" 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 "<img" in html
|
||||
|
||||
|
||||
def test_scale_figure_marks_unscheduled_and_bands():
|
||||
card = fixtures.andrey_card()
|
||||
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
|
||||
|
||||
@@ -23,12 +23,13 @@ class FakeApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.extra_alerts: list = []
|
||||
self.patients = list(fixtures.PATIENTS)
|
||||
|
||||
def __call__(self, req: httpx.Request) -> httpx.Response:
|
||||
path = req.url.path.removeprefix("/api/v1")
|
||||
self.calls.append((req.method, path))
|
||||
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-"):
|
||||
card = fixtures.card(path.rsplit("/", 1)[1])
|
||||
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
|
||||
html = _html(at)
|
||||
assert "Кабинет д-ра Смирновой Е. А." in html
|
||||
assert "Пациенты под наблюдением" in html
|
||||
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
|
||||
assert "Высокий · 70" in html # discontinuation-risk badge
|
||||
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):
|
||||
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
|
||||
at.button(key="open-tok-andrey").click().run()
|
||||
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 "Решения принимает врач" in html
|
||||
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):
|
||||
@@ -103,6 +177,8 @@ def test_api_down_shows_banner(monkeypatch):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user