feat(dashboard): replace home with approved minimal design
CI / ruff · pytest (pull_request) Successful in 2m1s

Refs #73
This commit is contained in:
Pavel Eliseev
2026-10-04 14:54:14 +03:00
parent a2b1b2e7a5
commit c01bfc90df
8 changed files with 337 additions and 131 deletions
+41 -24
View File
@@ -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>'