feat(web): two-stage questionnaires, screening result and diary by result
- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,18 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Focus } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
|
||||
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
|
||||
const NO_ICONS: IconName[] = ["user", "doc"];
|
||||
const FOCUS: { id: Focus; icon: IconName }[] = [
|
||||
{ id: "depression", icon: "smile" },
|
||||
{ id: "ptsd", icon: "heart" },
|
||||
];
|
||||
|
||||
// A3 · Анонимный вход
|
||||
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
|
||||
export function AnonymousSignIn() {
|
||||
const t = ru.anonymous;
|
||||
const navigate = useNavigate();
|
||||
@@ -18,11 +21,11 @@ export function AnonymousSignIn() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const start = async () => {
|
||||
const start = async (focus: Focus) => {
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
try {
|
||||
signIn(await api.signInAnonymous());
|
||||
signIn(await api.signInAnonymous(focus));
|
||||
navigate("/flow", { replace: true });
|
||||
} catch {
|
||||
setError(true);
|
||||
@@ -34,58 +37,28 @@ export function AnonymousSignIn() {
|
||||
<AppShell signedIn={false} focus>
|
||||
<div className="focus">
|
||||
<TopBar onBack={() => navigate("/")} />
|
||||
<div className="split">
|
||||
<div className="split__lead">
|
||||
<header className="head">
|
||||
<span className="tile-icon">
|
||||
<Icon name="lock" size={26} />
|
||||
</span>
|
||||
<div className="stack signin">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
<Notice tone="warn">{t.warning}</Notice>
|
||||
</div>
|
||||
<div className="split__act">
|
||||
<section className="panel panel--teal">
|
||||
<p className="panel__title">
|
||||
<span className="badge-icon badge-icon--teal">
|
||||
<Icon name="check" size={16} />
|
||||
</span>
|
||||
{t.yesTitle}
|
||||
</p>
|
||||
<ul className="iconlist">
|
||||
{t.yes.map((item, i) => (
|
||||
<li key={item}>
|
||||
<Icon name={YES_ICONS[i]} size={20} />
|
||||
{item}
|
||||
</li>
|
||||
|
||||
<div className="entry-grid">
|
||||
{FOCUS.map(({ id, icon }) => (
|
||||
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon">
|
||||
<Icon name={icon} size={26} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.focus[id].title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.focus[id].text}</span>
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel panel--red">
|
||||
<p className="panel__title">
|
||||
<span className="badge-icon badge-icon--red">
|
||||
<Icon name="x" size={16} />
|
||||
</span>
|
||||
{t.noTitle}
|
||||
</p>
|
||||
<ul className="iconlist">
|
||||
{t.no.map((item, i) => (
|
||||
<li key={item}>
|
||||
<Icon name={NO_ICONS[i]} size={20} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
|
||||
<button type="button" className="btn btn--primary" onClick={start} disabled={busy}>
|
||||
{busy ? t.submitting : t.submit}
|
||||
</button>
|
||||
</div>
|
||||
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
<p className="muted small">{busy ? t.submitting : t.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
+92
-67
@@ -1,15 +1,15 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Catalog, DialogButton, Section } from "../api/types";
|
||||
import type { Catalog, DialogButton } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { useCatalog } from "../content/reference";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { classify, lines, type Step, type Survey } from "../dialog/step";
|
||||
import { dayMonth } from "./useToday";
|
||||
|
||||
/*
|
||||
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
|
||||
@@ -17,16 +17,6 @@ import { dayMonth } from "./useToday";
|
||||
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
|
||||
*/
|
||||
|
||||
export const SECTION_ICON: Record<Section, IconName> = {
|
||||
safety: "shield",
|
||||
mood: "smile",
|
||||
trauma: "heart",
|
||||
alcohol: "info",
|
||||
about: "user",
|
||||
diary: "book",
|
||||
scale: "clipboard",
|
||||
};
|
||||
|
||||
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
|
||||
|
||||
function RichText({ text }: { text: string }) {
|
||||
@@ -91,19 +81,6 @@ function Progress({ n, total, adaptive = false }: { n: number; total: number; ad
|
||||
);
|
||||
}
|
||||
|
||||
function FormChip({ section, title }: { section: Section; title?: string }) {
|
||||
return (
|
||||
<div className="formchip">
|
||||
<span className={`chip chip--sec chip--${section}`}>
|
||||
<Icon name={SECTION_ICON[section]} size={16} />
|
||||
{ru.flow.sections[section]}
|
||||
</span>
|
||||
{/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */}
|
||||
{title && section !== "safety" && <span className="formchip__title">{title}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
|
||||
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
|
||||
return (
|
||||
@@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]>
|
||||
</table>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
{table.title} {table.note}
|
||||
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
|
||||
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
@@ -248,6 +226,9 @@ function NumberScale({
|
||||
);
|
||||
}
|
||||
|
||||
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
|
||||
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
|
||||
|
||||
function FreeText({
|
||||
skip,
|
||||
busy,
|
||||
@@ -291,20 +272,34 @@ function FreeText({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={!text.trim() || busy}
|
||||
onClick={() => onSend(text.trim())}
|
||||
disabled={busy}
|
||||
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
|
||||
>
|
||||
{ru.common.next}
|
||||
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
|
||||
const NUMERIC = {
|
||||
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
|
||||
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
|
||||
};
|
||||
|
||||
function HoursInput({
|
||||
busy,
|
||||
onSend,
|
||||
kind = "hours",
|
||||
}: {
|
||||
busy: boolean;
|
||||
onSend: (text: string) => void;
|
||||
kind?: keyof typeof NUMERIC;
|
||||
}) {
|
||||
const f = NUMERIC[kind];
|
||||
const [v, setV] = useState("");
|
||||
const n = Number(v.replace(",", "."));
|
||||
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
|
||||
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
|
||||
return (
|
||||
<form
|
||||
className="hours"
|
||||
@@ -314,7 +309,7 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hours" className="hours__label">
|
||||
{ru.flow.hoursLabel}
|
||||
{f.label}
|
||||
</label>
|
||||
<div className="hours__field">
|
||||
<input
|
||||
@@ -325,10 +320,10 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
|
||||
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
|
||||
aria-describedby="hours-hint"
|
||||
/>
|
||||
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
|
||||
<span aria-hidden="true">{f.unit}</span>
|
||||
</div>
|
||||
<p id="hours-hint" className="muted small">
|
||||
{ru.flow.hoursHint}
|
||||
{f.hint}
|
||||
</p>
|
||||
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
|
||||
{ru.common.next}
|
||||
@@ -337,13 +332,12 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
|
||||
);
|
||||
}
|
||||
|
||||
// No form name or section above the question: the person should not see what a questionnaire screens for while
|
||||
// answering it. Names and results appear only after it is finished (Results screen).
|
||||
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
|
||||
const item = catalog?.items[step.itemId];
|
||||
const form = item ? catalog?.forms[item.form] : undefined;
|
||||
return (
|
||||
<>
|
||||
<header className="head survey">
|
||||
{form && <FormChip section={form.section} title={form.title} />}
|
||||
{step.instruction.length > 0 && (
|
||||
<div className="survey__instr">
|
||||
<Body texts={step.instruction} />
|
||||
@@ -355,12 +349,6 @@ function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }
|
||||
{f}
|
||||
</p>
|
||||
))}
|
||||
{step.asOf && (
|
||||
<p className="survey__asof">
|
||||
<Icon name="calendar" size={16} />
|
||||
{ru.flow.asOf(dayMonth(step.asOf))}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
|
||||
</>
|
||||
@@ -373,12 +361,14 @@ function SurveyAct({
|
||||
busy,
|
||||
press,
|
||||
say,
|
||||
onPause,
|
||||
}: {
|
||||
step: Survey;
|
||||
stepKey: string;
|
||||
busy: boolean;
|
||||
press: (b: DialogButton) => void;
|
||||
say: (text: string) => void;
|
||||
onPause: (b: DialogButton) => void;
|
||||
}) {
|
||||
let answer: ReactNode;
|
||||
switch (step.input) {
|
||||
@@ -386,7 +376,8 @@ function SurveyAct({
|
||||
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
|
||||
break;
|
||||
case "hours":
|
||||
answer = <HoursInput key={stepKey} busy={busy} onSend={say} />;
|
||||
case "portions":
|
||||
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
|
||||
break;
|
||||
case "text":
|
||||
answer = (
|
||||
@@ -410,7 +401,7 @@ function SurveyAct({
|
||||
{answer}
|
||||
<div className="survey__aux">
|
||||
{step.pause && (
|
||||
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
|
||||
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
|
||||
<Icon name="pause" size={18} />
|
||||
{step.pause.label}
|
||||
</button>
|
||||
@@ -422,7 +413,6 @@ function SurveyAct({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small survey__saved">{ru.flow.saved}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -467,25 +457,40 @@ function Breathing() {
|
||||
);
|
||||
}
|
||||
|
||||
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
|
||||
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
|
||||
// explains it. Results stay in «Мои результаты» with the date.
|
||||
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
|
||||
const [head = ru.flow.doneTitle, ...rest] = notes
|
||||
.join("\n\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
<div className="stack narrow done">
|
||||
<span className="done__badge" aria-hidden="true">
|
||||
<Icon name="check" size={40} />
|
||||
</span>
|
||||
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
|
||||
<div className="panel">
|
||||
<Body texts={notes} />
|
||||
</div>
|
||||
<h1 className="h1 center">{head}</h1>
|
||||
{rest.length > 0 && (
|
||||
<div className="panel">
|
||||
<Body texts={rest} />
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn--primary" onClick={onHome}>
|
||||
{ru.common.toToday}
|
||||
</button>
|
||||
<button type="button" className="btn btn--soft" onClick={onResults}>
|
||||
{ru.today.results}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SHORT_PARAGRAPHS = 3;
|
||||
|
||||
export function Flow() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const dialog = useDialog();
|
||||
const { resources } = useHelp();
|
||||
const catalog = useCatalog();
|
||||
@@ -502,11 +507,36 @@ export function Flow() {
|
||||
dialog.markRead();
|
||||
}, [dialog.pending]);
|
||||
|
||||
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
|
||||
// result (the conclusion of the first questionnaire) keeps its own screen.
|
||||
const toasted = useRef("");
|
||||
useEffect(() => {
|
||||
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
|
||||
}, [step, navigate]);
|
||||
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
|
||||
const texts = step.kind === "ack" ? step.text : step.notes;
|
||||
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
||||
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
|
||||
if (paragraphs.length && toasted.current !== stepKey) {
|
||||
toasted.current = stepKey;
|
||||
toast(paragraphs.join(" "));
|
||||
}
|
||||
navigate("/today", { replace: true });
|
||||
}, [step, stepKey, navigate, toast]);
|
||||
|
||||
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
|
||||
// The browser «back» is absorbed by a duplicate history entry of this same screen.
|
||||
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
|
||||
useEffect(() => {
|
||||
if (!testing) return;
|
||||
window.history.pushState(null, "", window.location.href);
|
||||
const stay = () => window.history.pushState(null, "", window.location.href);
|
||||
window.addEventListener("popstate", stay);
|
||||
return () => window.removeEventListener("popstate", stay);
|
||||
}, [testing]);
|
||||
|
||||
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
|
||||
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
|
||||
const pause = (b: DialogButton) =>
|
||||
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
|
||||
const say = (text: string) => void dialog.send({ type: "text", text }, text);
|
||||
|
||||
let content: ReactNode;
|
||||
@@ -521,8 +551,10 @@ export function Flow() {
|
||||
) : (
|
||||
<div className="loader" aria-label="Загрузка" />
|
||||
);
|
||||
} else if (step.kind === "ack") {
|
||||
content = <div className="loader" aria-label="Загрузка" />;
|
||||
} else if (step.kind === "idle") {
|
||||
content = <Done notes={step.notes} onHome={home} />;
|
||||
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
|
||||
} else if (step.kind === "notice") {
|
||||
content = (
|
||||
<div className="stack narrow done">
|
||||
@@ -556,7 +588,7 @@ export function Flow() {
|
||||
) : item ? (
|
||||
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
|
||||
) : null;
|
||||
const top = <TopBar onBack={home}>{progress}</TopBar>;
|
||||
const top = <TopBar onBack={testing ? undefined : home}>{progress}</TopBar>;
|
||||
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
|
||||
let lead: ReactNode;
|
||||
let act: ReactNode;
|
||||
@@ -670,19 +702,12 @@ export function Flow() {
|
||||
break;
|
||||
case "survey":
|
||||
lead = <SurveyLead step={step} catalog={catalog} />;
|
||||
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
|
||||
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />;
|
||||
break;
|
||||
case "choice": {
|
||||
const form = step.formId ? catalog?.forms[step.formId] : undefined;
|
||||
lead = (
|
||||
<>
|
||||
{form && <FormChip section={form.section} title={form.title} />}
|
||||
<Head title={step.title} lead={step.lead} />
|
||||
</>
|
||||
);
|
||||
case "choice":
|
||||
lead = <Head title={step.title} lead={step.lead} />;
|
||||
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
|
||||
break;
|
||||
}
|
||||
}
|
||||
content = (
|
||||
<div className="focus" key={stepKey}>
|
||||
@@ -699,7 +724,7 @@ export function Flow() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell focus sos={step?.kind !== "crisis"}>
|
||||
<AppShell focus bare sos={step?.kind !== "crisis"}>
|
||||
{content}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
@@ -14,8 +15,18 @@ export function Profile() {
|
||||
const { session, signOut } = useSession();
|
||||
const dialog = useDialog();
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [conclusion, setConclusion] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
// The preliminary conclusion of stage 1 is part of the profile until the doctor's diagnosis replaces it.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
api
|
||||
.results(session.token)
|
||||
.then((r) => setConclusion(r.conclusion?.title ?? null))
|
||||
.catch(() => undefined);
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
@@ -68,6 +79,26 @@ export function Profile() {
|
||||
|
||||
<section className="stack stack--sm">
|
||||
<h2 className="h3">{t.data}</h2>
|
||||
<button type="button" className="row-card" onClick={() => navigate("/results")}>
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
<Icon name="clipboard" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.result}</span>
|
||||
<span className="row-card__sub">{conclusion ?? t.resultNone}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
<button type="button" className="row-card" onClick={() => navigate("/results")}>
|
||||
<span className="tile-icon">
|
||||
<Icon name="book" />
|
||||
</span>
|
||||
<span className="row-card__text">
|
||||
<span className="row-card__title">{t.history}</span>
|
||||
<span className="row-card__sub">{t.historySub}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
|
||||
<span className="tile-icon">
|
||||
<Icon name="message" />
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Results as ResultsData } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { dayMonth } from "./useToday";
|
||||
|
||||
/*
|
||||
* «Мои результаты»: the preliminary conclusion of stage 1 (a screening result, not a diagnosis) and the log of every
|
||||
* questionnaire with its date, status and the interpretation shown when it was finished. Data come from the server
|
||||
* (`/me/{token}/results`), so the log is the same on every device.
|
||||
*/
|
||||
export function Results() {
|
||||
const t = ru.results;
|
||||
const navigate = useNavigate();
|
||||
const { session } = useSession();
|
||||
const [data, setData] = useState<ResultsData | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
api
|
||||
.results(session.token)
|
||||
.then(setData)
|
||||
.catch(() => setFailed(true));
|
||||
}, [session]);
|
||||
|
||||
const c = data?.conclusion;
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack">
|
||||
<TopBar onBack={() => navigate("/profile")} title={t.title} />
|
||||
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
{!data && !failed && <div className="loader" aria-label="Загрузка" />}
|
||||
|
||||
{data && (
|
||||
<section className="stack stack--sm" aria-labelledby="conclusion">
|
||||
<h2 id="conclusion" className="h3">
|
||||
{t.conclusion}
|
||||
</h2>
|
||||
{c ? (
|
||||
<div className="result">
|
||||
<p className="muted small">{t.conclusionDay(dayMonth(c.day))}</p>
|
||||
<p className="result__title">{c.title}</p>
|
||||
{c.findings.length > 0 && (
|
||||
<div className="result__chips">
|
||||
{c.findings.map((f) => (
|
||||
<span key={f.code} className="chip chip--teal">
|
||||
{f.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{c.lines.map((l) => (
|
||||
<p key={l}>{l}</p>
|
||||
))}
|
||||
{c.modules.length > 0 && (
|
||||
<p className="muted small">{c.modules.map((m) => t.modules[m as keyof typeof t.modules] ?? m).join(" · ")}</p>
|
||||
)}
|
||||
<p className="muted small">{c.disclaimer}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Notice>{t.noResult}</Notice>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<section className="stack stack--sm" aria-labelledby="history">
|
||||
<h2 id="history" className="h3">
|
||||
{t.history}
|
||||
</h2>
|
||||
{data.history.length === 0 && <p className="muted">{t.empty}</p>}
|
||||
<ul className="log">
|
||||
{data.history.map((h) => {
|
||||
const expanded = open === h.id;
|
||||
return (
|
||||
<li key={h.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="log__row"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen(expanded ? null : h.id)}
|
||||
disabled={!h.title && h.lines.length === 0}
|
||||
>
|
||||
<span className="log__date">{dayMonth(h.day)}</span>
|
||||
<span className="log__text">
|
||||
<span className="log__kind">{h.kind_title}</span>
|
||||
<span className="log__sub">{h.title ?? h.status_title}</span>
|
||||
</span>
|
||||
{(h.title || h.lines.length > 0) && <Icon name={expanded ? "chevronDown" : "chevronRight"} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="log__body">
|
||||
<p className="muted small">
|
||||
{h.status_title}
|
||||
{h.completed_at ? ` · ${new Date(h.completed_at).toLocaleString("ru-RU")}` : ""}
|
||||
</p>
|
||||
{h.lines.map((l) => (
|
||||
<p key={l}>{l}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { classify } from "../dialog/step";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { SECTION_ICON } from "./Flow";
|
||||
import { WeekDots } from "./Meds";
|
||||
import { humanDate, useToday } from "./useToday";
|
||||
|
||||
@@ -92,7 +91,9 @@ export function Today() {
|
||||
|
||||
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
|
||||
const waiting = classify(dialog.pending);
|
||||
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
|
||||
// A paused questionnaire is not an open step: the «continue the questionnaire» card below resumes it.
|
||||
const paused = waiting?.kind === "notice" && waiting.primary?.data === "intake:resume";
|
||||
const hasOpenStep = waiting !== null && waiting.kind !== "idle" && waiting.kind !== "ack" && !paused;
|
||||
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
|
||||
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
|
||||
|
||||
@@ -151,13 +152,9 @@ export function Today() {
|
||||
(waiting?.kind === "survey" ? (
|
||||
<Task
|
||||
hero
|
||||
icon={surveyForm ? SECTION_ICON[surveyForm.section] : "clipboard"}
|
||||
icon={surveyForm?.section === "diary" ? "book" : "clipboard"}
|
||||
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
|
||||
sub={
|
||||
surveyForm && surveyItem
|
||||
? t.surveyStep(ru.flow.sections[surveyForm.section], surveyItem.index)
|
||||
: t.intakeResumeHint
|
||||
}
|
||||
sub={t.intakeResumeHint}
|
||||
chip={t.continue}
|
||||
onClick={() => navigate("/flow")}
|
||||
/>
|
||||
@@ -240,10 +237,10 @@ export function Today() {
|
||||
{diaryDue && waiting?.kind !== "survey" && (
|
||||
<Task
|
||||
icon="book"
|
||||
title={t.diary}
|
||||
sub={t.diaryHint}
|
||||
title={q.diary_next ? t.diaryPart[q.diary_next] : t.diary}
|
||||
sub={q.diary_next ? t.diaryPartHint[q.diary_next] : t.diaryHint}
|
||||
disabled={dialog.busy}
|
||||
onClick={() => go("menu:checkin", t.diary)}
|
||||
onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : t.diary)}
|
||||
/>
|
||||
)}
|
||||
{plan && !surveys && !plan.checkin_done && (
|
||||
|
||||
+52
-46
@@ -1,70 +1,76 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { telHref, useHelp } from "../components/HelpSheet";
|
||||
import { Icon, LogoMark } from "../components/Icon";
|
||||
import { ru } from "../content/ru";
|
||||
|
||||
// A1 · Выбор способа входа
|
||||
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
|
||||
export function Welcome() {
|
||||
const navigate = useNavigate();
|
||||
const { resources, open } = useHelp();
|
||||
const t = ru.welcome;
|
||||
const emergency = resources[0];
|
||||
|
||||
return (
|
||||
<AppShell signedIn={false}>
|
||||
<div className="stack">
|
||||
<header className="head">
|
||||
<div className="signin-page">
|
||||
<main className="signin" id="main">
|
||||
<div className="signin__brand">
|
||||
<LogoMark size={44} />
|
||||
<span>
|
||||
<span className="signin__name">{ru.product.name}</span>
|
||||
<span className="signin__tag">{ru.product.tagline}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="signin__card">
|
||||
<h1 className="h1">{t.title}</h1>
|
||||
<p className="lead">{t.lead}</p>
|
||||
</header>
|
||||
|
||||
<div className="entry-grid">
|
||||
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon tile-icon--teal">
|
||||
<Icon name="docPlus" size={26} />
|
||||
</span>
|
||||
<span className="chip chip--teal">{ru.common.recommended}</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.emias.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.emias.text}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
|
||||
<span className="entry__top">
|
||||
<span className="tile-icon">
|
||||
<Icon name="lock" size={26} />
|
||||
<span className="entry__title">
|
||||
{t.emias.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.anonymous.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.anonymous.text}</span>
|
||||
<ul className="signin__perks">
|
||||
{t.emias.perks.map((p) => (
|
||||
<li key={p}>
|
||||
<Icon name="check" size={18} />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="how" aria-labelledby="how-title">
|
||||
<h2 id="how-title" className="h3">
|
||||
{t.howTitle}
|
||||
</h2>
|
||||
<ol className="how__list">
|
||||
{t.how.map((step, i) => (
|
||||
<li key={step.title} className="how__item">
|
||||
<span className="how__num">{i + 1}</span>
|
||||
<span>
|
||||
<span className="how__title">{step.title}</span>
|
||||
<span className="how__text">{step.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="divider" role="separator">
|
||||
<span>{t.or}</span>
|
||||
</div>
|
||||
|
||||
<div className="stack stack--sm">
|
||||
<button type="button" className="btn btn--ghost" onClick={() => navigate("/anonymous")}>
|
||||
<Icon name="lock" size={20} />
|
||||
{t.anonymous.title}
|
||||
</button>
|
||||
<p className="muted small center">{t.anonymous.text}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="muted small">{t.disclaimer}</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
<div className="signin__foot">
|
||||
<button type="button" className="signin__sos" onClick={open}>
|
||||
<Icon name="phone" size={18} />
|
||||
{ru.help.block}
|
||||
</button>
|
||||
{emergency && (
|
||||
<a className="signin__tel" href={telHref(emergency.phone)}>
|
||||
{emergency.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small center">{t.disclaimer}</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user