Files
PTSRaiAgent/web/src/components/AppShell.tsx
T
Pavel Eliseev 2a2fdb171e
CI / ruff · pytest (pull_request) Successful in 2m16s
feat(ui): extend minimal doctor design across all screens
Refs #85
2026-10-04 16:09:57 +03:00

217 lines
6.8 KiB
TypeScript

import type { ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { HotlineCard, useHelp } from "./HelpSheet";
import { Icon, LogoMark, type IconName } from "./Icon";
/*
* Shared patient workspace, following the approved doctor dashboard.
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: white sidebar | workspace header | content + emergency panel
* bare: one centred column on every width (questionnaires)
*/
const TABS: { to: string; label: string; icon: IconName }[] = [
{ to: "/today", label: ru.nav.today, icon: "home" },
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
{ to: "/profile", label: ru.nav.profile, icon: "user" },
];
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
export function EmergencyBlock() {
const { resources, open } = useHelp();
return (
<section className="sosblock" aria-label={ru.help.block}>
<button type="button" className="sosblock__head" onClick={open}>
<span className="sosblock__icon">
<Icon name="phone" size={18} />
</span>
<span>{ru.help.block}</span>
<Icon name="chevronRight" size={20} />
</button>
<div className="sosblock__lines">
{resources.slice(0, 2).map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</div>
</section>
);
}
function Sidebar({ signedIn }: { signedIn: boolean }) {
const { open } = useHelp();
const { unread } = useDialog();
return (
<nav className="sidebar" aria-label="Разделы">
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
<LogoMark />
<span>
<span className="sidebar__name">{ru.product.name}</span>
<span className="sidebar__tag">{ru.product.tagline}</span>
</span>
</Link>
{signedIn ? (
<ul className="sidebar__list">
{TABS.map((t) => (
<li key={t.to}>
<NavLink to={t.to} className="sidebar__link">
<Icon name={t.icon} />
{t.label}
</NavLink>
</li>
))}
<li>
<NavLink to="/messages" className="sidebar__link">
<Icon name="message" />
{ru.nav.messages}
{unread > 0 && <span className="dot" aria-label="новое" />}
</NavLink>
</li>
</ul>
) : (
<ul className="sidebar__list">
<li>
<NavLink to="/" end className="sidebar__link">
<Icon name="home" />
{ru.nav.signIn}
</NavLink>
</li>
<li>
<button type="button" className="sidebar__link" onClick={open}>
<Icon name="question" />
{ru.nav.help}
</button>
</li>
</ul>
)}
<div className="sidebar__note">
<strong>{ru.product.supportTitle}</strong>
<span>{ru.product.supportText}</span>
</div>
</nav>
);
}
function BottomNav() {
return (
<nav className="tabbar" aria-label="Разделы">
{TABS.map((t) => (
<NavLink key={t.to} to={t.to} className="tabbar__item">
<Icon name={t.icon} />
<span>{t.label}</span>
</NavLink>
))}
</nav>
);
}
function Rail({ aside }: { aside?: ReactNode }) {
const { resources, open } = useHelp();
return (
<aside className="rail">
{aside}
<section className="railsos" aria-label={ru.help.block}>
<button type="button" className="railsos__head" onClick={open}>
<Icon name="phone" size={18} />
<span>{ru.help.block}</span>
</button>
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</section>
</aside>
);
}
function WorkspaceHeader({ signedIn, bare = false }: { signedIn: boolean; bare?: boolean }) {
const { session } = useSession();
return (
<header className={`workspace${bare ? " workspace--bare" : ""}`}>
<div className="workspace__brand">
<LogoMark size={30} />
<span>{bare ? ru.product.name : signedIn ? ru.product.workspace : ru.nav.signIn}</span>
</div>
{signedIn && !bare ? (
<Link className="workspace__profile" to="/profile">
<span className="avatar"><Icon name="user" size={18} /></span>
<span>{session?.displayName || ru.nav.profile}</span>
</Link>
) : <span className="workspace__tag">{ru.product.tagline}</span>}
</header>
);
}
export function AppShell({
children,
signedIn = true,
aside,
focus = false,
sos = true,
bare = false,
}: {
children: ReactNode;
signedIn?: boolean;
aside?: ReactNode;
/** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */
focus?: boolean;
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
sos?: boolean;
/** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */
bare?: boolean;
}) {
if (bare)
return (
<div className="app app--bare">
<div className="app__main">
<WorkspaceHeader signedIn={signedIn} bare />
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
</div>
</div>
);
return (
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} />
<div className="app__main">
<WorkspaceHeader signedIn={signedIn} />
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
<Rail aside={aside} />
</div>
{signedIn && !focus && <BottomNav />}
</div>
);
}
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
return (
<div className={`notice notice--${tone}`} role="note">
<Icon name={icon} />
<div>{children}</div>
</div>
);
}
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
return (
<div className="topbar">
{onBack && (
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
<Icon name="arrowLeft" />
</button>
)}
{title && <span className="topbar__title">{title}</span>}
{children}
</div>
);
}