feat(web): questionnaires, diary and medication module in the patient app
- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip, sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table. - Paused / held-for-review questionnaires get their own screen. - Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14. - Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags; no doses shown. "Report a side effect" always available (med:side_effects:<ref>). - API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id, indication and therapy day; demo persona seeds the dashboard dose history. - INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching the clinical release gate in config/intake_runtime.yaml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import type { DrugRef, Indication, MedsReference } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { useMedsReference } from "../content/reference";
|
||||
import { ru } from "../content/ru";
|
||||
import { useDialog } from "../dialog/DialogContext";
|
||||
import { sendSideEffect } from "./Meds";
|
||||
import { useToday } from "./useToday";
|
||||
|
||||
/*
|
||||
* E6 · О препарате (spec «Модуль Лекарства»: карточка препарата). Patient information from the drug reference
|
||||
* built on the Minzdrav guidelines: when to expect the effect, how long, what may bother, when to see a doctor
|
||||
* urgently. No doses or ranges are shown — the prescription comes from the doctor only.
|
||||
*/
|
||||
|
||||
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
/** «3–4 недели» → the upper bound in days, for the progress bar (null when the text has no weeks). */
|
||||
function weeksToDays(text: string | null | undefined): number | null {
|
||||
const m = /(\d+)(?:\s*[–-]\s*(\d+))?\s*недел/.exec(text ?? "");
|
||||
return m ? Number(m[2] ?? m[1]) * 7 : null;
|
||||
}
|
||||
|
||||
function Section({ icon, tone, title, children }: { icon: IconName; tone?: string; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="drug__sec">
|
||||
<h2 className="drug__h">
|
||||
<span className={`badge-icon ${tone ? `badge-icon--${tone}` : ""}`}>
|
||||
<Icon name={icon} size={18} />
|
||||
</span>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectTimeline({ day, horizon, when }: { day: number; horizon: number; when: string }) {
|
||||
const pct = Math.min(100, (day / horizon) * 100);
|
||||
return (
|
||||
<div className="timeline" aria-label={ru.meds.card.progress(day)}>
|
||||
<div className="timeline__bar">
|
||||
<span className="timeline__fill" style={{ width: `${pct}%` }} />
|
||||
<span className="timeline__mark" style={{ left: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="timeline__legend">
|
||||
<span>{ru.meds.card.progress(day)}</span>
|
||||
<span>{ru.meds.card.effectAt(when)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
drug,
|
||||
reference: ref,
|
||||
indication,
|
||||
day,
|
||||
prescribed,
|
||||
}: {
|
||||
drug: DrugRef;
|
||||
reference: MedsReference;
|
||||
indication: Indication | null;
|
||||
day: number | null;
|
||||
prescribed?: string;
|
||||
}) {
|
||||
const c = ru.meds.card;
|
||||
const ind = indication ? drug.indications[indication] : Object.values(drug.indications)[0];
|
||||
const expect = indication === "ptsd" ? ref.texts.expect_effect_ptsd : ref.texts.expect_effect_depression;
|
||||
const horizon = weeksToDays(ind?.expected_response);
|
||||
const sources = ind?.source ?? "";
|
||||
|
||||
return (
|
||||
<div className="drug">
|
||||
<header className="drug__head">
|
||||
<span className="tile-icon tile-icon--teal tile-icon--xl">
|
||||
<Icon name="pill" size={32} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="h1">{drug.name_ru}</h1>
|
||||
<p className="muted">
|
||||
{drug.group}
|
||||
{drug.group !== drug.class && `, ${drug.class}`}
|
||||
</p>
|
||||
{prescribed && (
|
||||
<p className="drug__rx">
|
||||
<Icon name="doctor" size={18} />
|
||||
{c.prescribed}: {prescribed}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="drug__grid">
|
||||
<div className="drug__col">
|
||||
{ind?.expected_response && (
|
||||
<Section icon="clock" tone="blue" title={c.effect}>
|
||||
{day !== null && horizon && <EffectTimeline day={day} horizon={horizon} when={ind.expected_response} />}
|
||||
<p>{expect}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(ind?.course || drug.max_course_days) && (
|
||||
<Section icon="calendar" tone="blue" title={c.course}>
|
||||
{ind?.course && <p>{ind.course}.</p>}
|
||||
{drug.max_course_days && <p>{c.courseShort(drug.max_course_days)}.</p>}
|
||||
{indication === "depression" && <p className="muted">{ref.texts.why_continue_depression}</p>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section icon="smile" tone="teal" title={c.sideEffects}>
|
||||
{drug.diary_items.length ? (
|
||||
<ul className="bullets">
|
||||
{drug.diary_items.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>{c.sideEffectsNone}</p>
|
||||
)}
|
||||
<p className="muted">{c.sideEffectsHint}</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="drug__col">
|
||||
{drug.red_flags.length > 0 && (
|
||||
<section className="drug__sec drug__sec--red">
|
||||
<h2 className="drug__h">
|
||||
<span className="badge-icon badge-icon--red">
|
||||
<Icon name="alert" size={18} />
|
||||
</span>
|
||||
{c.redFlags}
|
||||
</h2>
|
||||
<ul className="bullets">
|
||||
{drug.red_flags.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>{ref.texts.red_flag}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Section icon="shield" tone="blue" title={c.important}>
|
||||
<p>{ref.texts.dont_stop_alone}</p>
|
||||
{drug.withdrawal_risk === "high" && <p>{c.withdrawal}</p>}
|
||||
{drug.overdose_danger && <p>{c.overdose}</p>}
|
||||
{ind?.off_label && <p className="muted">{c.offLabel}</p>}
|
||||
{drug.monitoring.length > 0 && (
|
||||
<>
|
||||
<p className="drug__sub">{c.monitoring}:</p>
|
||||
<ul className="bullets">
|
||||
{drug.monitoring.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon="doc" tone="blue" title={c.instruction}>
|
||||
<p>{ref.texts.instruction_fallback}</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="muted small">{c.source(sources)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrugInfo() {
|
||||
const c = ru.meds.card;
|
||||
const { ref: refId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { plan } = useToday();
|
||||
const ref = useMedsReference();
|
||||
const drug = ref?.drugs.find((d) => d.id === refId);
|
||||
const rx = plan?.prescriptions.find((p) => p.ref_id === refId);
|
||||
|
||||
const ask = () =>
|
||||
void dialog.send({ type: "button", button: "menu:med_question" }, c.ask).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="stack">
|
||||
<TopBar onBack={() => navigate("/meds")} />
|
||||
{!ref ? (
|
||||
<div className="loader" aria-label="Загрузка" />
|
||||
) : !drug ? (
|
||||
<Notice>{c.unknown}</Notice>
|
||||
) : (
|
||||
<Card
|
||||
drug={drug}
|
||||
reference={ref}
|
||||
indication={rx?.indication ?? null}
|
||||
day={rx?.therapy_day ?? null}
|
||||
prescribed={rx ? `${cap(rx.drug)}, ${rx.dose} · ${rx.times.join(", ")}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{rx && (
|
||||
<div className="btn-row btn-row--stack-phone">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--outline-red"
|
||||
disabled={dialog.busy}
|
||||
onClick={() => sendSideEffect(dialog, navigate, plan, rx.drug, ru.meds.reportSideEffect)}
|
||||
>
|
||||
<Icon name="alert" />
|
||||
{ru.meds.reportSideEffect}
|
||||
</button>
|
||||
{plan?.has_doctor && (
|
||||
<button type="button" className="btn btn--outline" disabled={dialog.busy} onClick={ask}>
|
||||
<Icon name="question" />
|
||||
{c.ask}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
+258
-12
@@ -1,19 +1,32 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { DialogButton } from "../api/types";
|
||||
import type { Catalog, DialogButton, Section } from "../api/types";
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
|
||||
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 } from "../dialog/step";
|
||||
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),
|
||||
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3).
|
||||
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3),
|
||||
* 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 }) {
|
||||
@@ -60,7 +73,14 @@ function Body({ texts }: { texts: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ n, total }: { n: number; total: number }) {
|
||||
function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
|
||||
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
|
||||
if (adaptive)
|
||||
return (
|
||||
<div className="progress">
|
||||
<span className="progress__label">{ru.flow.questionN(n)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
|
||||
<span className="progress__bar">
|
||||
@@ -71,6 +91,56 @@ function Progress({ n, total }: { n: number; total: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="drinks" aria-label={ru.flow.drinks}>
|
||||
<h2 className="h3">{ru.flow.drinks}</h2>
|
||||
<p className="muted small">{ru.flow.drinksLead}</p>
|
||||
<div className="drinks__scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">СП</th>
|
||||
{table.header.map((h) => (
|
||||
<th key={h} scope="col">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map(([label, cells]) => (
|
||||
<tr key={label}>
|
||||
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
|
||||
{cells.map((c, i) => (
|
||||
<td key={i}>{c}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
{table.title} {table.note}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
|
||||
return (
|
||||
<header className="head">
|
||||
@@ -131,10 +201,12 @@ function NumberScale({
|
||||
options,
|
||||
busy,
|
||||
onSubmit,
|
||||
anchors,
|
||||
}: {
|
||||
options: DialogButton[];
|
||||
busy: boolean;
|
||||
onSubmit: (b: DialogButton) => void;
|
||||
anchors?: string[];
|
||||
}) {
|
||||
const [picked, setPicked] = useState<DialogButton | null>(null);
|
||||
return (
|
||||
@@ -143,7 +215,7 @@ function NumberScale({
|
||||
<span className="numscale__value" aria-live="polite">
|
||||
{picked ? picked.label : "–"}
|
||||
</span>
|
||||
<div className="numscale__row" role="radiogroup">
|
||||
<div className="numscale__row" role="radiogroup" style={{ "--n": options.length } as CSSProperties}>
|
||||
{options.map((b) => (
|
||||
<button
|
||||
key={b.data}
|
||||
@@ -157,6 +229,12 @@ function NumberScale({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{anchors && anchors.length === 2 && (
|
||||
<div className="numscale__ends">
|
||||
<span>{anchors[0]}</span>
|
||||
<span>{anchors[1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -223,6 +301,132 @@ function FreeText({
|
||||
);
|
||||
}
|
||||
|
||||
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
|
||||
const [v, setV] = useState("");
|
||||
const n = Number(v.replace(",", "."));
|
||||
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
|
||||
return (
|
||||
<form
|
||||
className="hours"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ok) onSend(v.trim());
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hours" className="hours__label">
|
||||
{ru.flow.hoursLabel}
|
||||
</label>
|
||||
<div className="hours__field">
|
||||
<input
|
||||
id="hours"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
|
||||
aria-describedby="hours-hint"
|
||||
/>
|
||||
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
|
||||
</div>
|
||||
<p id="hours-hint" className="muted small">
|
||||
{ru.flow.hoursHint}
|
||||
</p>
|
||||
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
|
||||
{ru.common.next}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="h1 survey__q">{step.title}</h1>
|
||||
{step.footnotes.map((f) => (
|
||||
<p key={f} className="muted small">
|
||||
{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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SurveyAct({
|
||||
step,
|
||||
stepKey,
|
||||
busy,
|
||||
press,
|
||||
say,
|
||||
}: {
|
||||
step: Survey;
|
||||
stepKey: string;
|
||||
busy: boolean;
|
||||
press: (b: DialogButton) => void;
|
||||
say: (text: string) => void;
|
||||
}) {
|
||||
let answer: ReactNode;
|
||||
switch (step.input) {
|
||||
case "number":
|
||||
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} />;
|
||||
break;
|
||||
case "text":
|
||||
answer = (
|
||||
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={() => undefined} />
|
||||
);
|
||||
break;
|
||||
default:
|
||||
answer = (
|
||||
<>
|
||||
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
|
||||
{step.skip && (
|
||||
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
|
||||
{step.skip.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{answer}
|
||||
<div className="survey__aux">
|
||||
{step.pause && (
|
||||
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
|
||||
<Icon name="pause" size={18} />
|
||||
{step.pause.label}
|
||||
</button>
|
||||
)}
|
||||
{step.help && (
|
||||
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
|
||||
<Icon name="alert" size={18} />
|
||||
{step.help.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small survey__saved">{ru.flow.saved}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
|
||||
const style = (b: DialogButton) =>
|
||||
b.data.startsWith("med:taken")
|
||||
@@ -284,6 +488,7 @@ export function Flow() {
|
||||
const navigate = useNavigate();
|
||||
const dialog = useDialog();
|
||||
const { resources } = useHelp();
|
||||
const catalog = useCatalog();
|
||||
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
|
||||
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
|
||||
const home = () => navigate("/today");
|
||||
@@ -318,10 +523,40 @@ export function Flow() {
|
||||
);
|
||||
} else if (step.kind === "idle") {
|
||||
content = <Done notes={step.notes} onHome={home} />;
|
||||
} else {
|
||||
const top = (
|
||||
<TopBar onBack={home}>{step.kind === "choice" && step.progress && <Progress {...step.progress} />}</TopBar>
|
||||
} else if (step.kind === "notice") {
|
||||
content = (
|
||||
<div className="stack narrow done">
|
||||
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
|
||||
<Icon name={step.help ? "shield" : "pause"} size={32} />
|
||||
</span>
|
||||
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
|
||||
<div className="panel">
|
||||
<Body texts={step.text} />
|
||||
</div>
|
||||
{step.primary && (
|
||||
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
|
||||
{step.primary.label}
|
||||
</button>
|
||||
)}
|
||||
{step.help && (
|
||||
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
|
||||
{step.help.label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn--soft" onClick={home}>
|
||||
{ru.common.toToday}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
|
||||
const progress =
|
||||
step.kind === "choice" && step.progress ? (
|
||||
<Progress {...step.progress} />
|
||||
) : item ? (
|
||||
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
|
||||
) : null;
|
||||
const top = <TopBar onBack={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;
|
||||
@@ -433,15 +668,26 @@ export function Flow() {
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "choice":
|
||||
lead = <Head title={step.title} lead={step.lead} />;
|
||||
case "survey":
|
||||
lead = <SurveyLead step={step} catalog={catalog} />;
|
||||
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
|
||||
break;
|
||||
}
|
||||
}
|
||||
content = (
|
||||
<div className="focus" key={stepKey}>
|
||||
{top}
|
||||
<div className={`split split--${step.kind}`}>
|
||||
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
|
||||
<div className="split__lead">{lead}</div>
|
||||
<div className="split__act">
|
||||
{act}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import type { TodayPrescription } from "../api/types";
|
||||
import type { TodayPlan, TodayPrescription } from "../api/types";
|
||||
|
||||
import { AppShell, Notice, TopBar } from "../components/AppShell";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
@@ -30,6 +30,24 @@ export function WeekDots({ week }: { week: TodayPrescription["week"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* «Сообщить о побочном эффекте» — always available (spec «Модуль Лекарства»). Same engine step as the reminder's
|
||||
* «Есть побочные эффекты»: `med:side_effects:<ref>`, where ref is today's dose of the drug or, without a dose row,
|
||||
* today's ISO date (app/dialog/flows/meds.py).
|
||||
*/
|
||||
export function sendSideEffect(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
navigate: (to: string) => void,
|
||||
plan: TodayPlan | null,
|
||||
drug: string,
|
||||
label: string,
|
||||
) {
|
||||
if (!plan) return;
|
||||
const dose = plan.doses.find((d) => d.drug === drug && d.status === "pending") ?? plan.doses.find((d) => d.drug === drug);
|
||||
const ref = dose ? String(dose.id) : plan.today;
|
||||
void dialog.send({ type: "button", button: `med:side_effects:${ref}` }, label).then((ok) => ok && navigate("/flow"));
|
||||
}
|
||||
|
||||
// E4 · Моя схема приёма
|
||||
export function Meds() {
|
||||
const t = ru.meds;
|
||||
@@ -39,6 +57,7 @@ export function Meds() {
|
||||
|
||||
const ask = () =>
|
||||
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
|
||||
const reportSideEffect = (drug: string) => sendSideEffect(dialog, navigate, plan, drug, t.reportSideEffect);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -74,6 +93,23 @@ export function Meds() {
|
||||
))}
|
||||
</ul>
|
||||
<WeekDots week={p.week} />
|
||||
<div className="rx__foot">
|
||||
{p.ref_id && (
|
||||
<button type="button" className="btn btn--soft btn--sm" onClick={() => navigate(`/meds/${p.ref_id}`)}>
|
||||
<Icon name="info" size={18} />
|
||||
{t.about}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost-red btn--sm"
|
||||
disabled={dialog.busy}
|
||||
onClick={() => reportSideEffect(p.drug)}
|
||||
>
|
||||
<Icon name="alert" size={18} />
|
||||
{t.reportSideEffect}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+121
-25
@@ -2,14 +2,50 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { TodayDose, TodayPrescription } from "../api/types";
|
||||
import { AppShell, Notice } from "../components/AppShell";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { Icon, type IconName } from "../components/Icon";
|
||||
import { useCatalog } from "../content/reference";
|
||||
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";
|
||||
|
||||
// Spec «Модуль Лекарства»: the expected-effect card is pushed on these days of therapy.
|
||||
const EFFECT_DAYS = [1, 7, 14];
|
||||
|
||||
function Task({
|
||||
icon,
|
||||
title,
|
||||
sub,
|
||||
hero,
|
||||
chip,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
sub: string;
|
||||
hero?: boolean;
|
||||
chip?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={hero ? "task task--hero" : "task"} onClick={onClick} disabled={disabled}>
|
||||
<span className={hero ? "task__icon" : "task__icon task__icon--teal"}>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{title}</span>
|
||||
<span className="task__sub">{sub}</span>
|
||||
</span>
|
||||
{chip ? <span className="chip chip--teal-solid">{chip}</span> : <Icon name="chevronRight" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
|
||||
return (
|
||||
<button type="button" className="row-card" onClick={onOpen}>
|
||||
@@ -52,17 +88,35 @@ export function Today() {
|
||||
const { session } = useSession();
|
||||
const dialog = useDialog();
|
||||
const { plan, failed } = useToday();
|
||||
const catalog = useCatalog();
|
||||
|
||||
// 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";
|
||||
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
|
||||
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
|
||||
|
||||
const go = (button: string, label: string) =>
|
||||
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
|
||||
|
||||
// Questionnaires (stage 1 intake → stage 2 diary) replace the simple check-in once they are switched on.
|
||||
const q = plan?.questionnaires;
|
||||
const surveys = q?.enabled === true;
|
||||
const intakeOpen = surveys && q.intake !== "complete" && q.intake !== "review";
|
||||
const diaryDue = surveys && q.intake === "complete" && !q.diary_done;
|
||||
const effectRx = plan?.prescriptions.find((p) => p.ref_id && p.therapy_day && EFFECT_DAYS.includes(p.therapy_day));
|
||||
|
||||
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
|
||||
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
|
||||
const allDone = plan !== null && pendingDoses.length === 0 && plan.checkin_done && !hasOpenStep;
|
||||
const doseNow = pendingDoses.some((d) => d.time <= (plan?.now ?? "00:00"));
|
||||
const allDone =
|
||||
plan !== null &&
|
||||
pendingDoses.length === 0 &&
|
||||
plan.checkin_done &&
|
||||
!hasOpenStep &&
|
||||
!intakeOpen &&
|
||||
!diaryDue &&
|
||||
q?.intake !== "review";
|
||||
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
|
||||
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor);
|
||||
|
||||
@@ -93,16 +147,44 @@ export function Today() {
|
||||
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
|
||||
<section className="dash__main" aria-label={t.title}>
|
||||
{hasOpenStep && (
|
||||
<button type="button" className="task task--hero" onClick={() => navigate("/flow")}>
|
||||
<span className="task__icon">
|
||||
<Icon name="message" />
|
||||
{hasOpenStep &&
|
||||
(waiting?.kind === "survey" ? (
|
||||
<Task
|
||||
hero
|
||||
icon={surveyForm ? SECTION_ICON[surveyForm.section] : "clipboard"}
|
||||
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
|
||||
sub={
|
||||
surveyForm && surveyItem
|
||||
? t.surveyStep(ru.flow.sections[surveyForm.section], surveyItem.index)
|
||||
: t.intakeResumeHint
|
||||
}
|
||||
chip={t.continue}
|
||||
onClick={() => navigate("/flow")}
|
||||
/>
|
||||
) : (
|
||||
<Task
|
||||
hero
|
||||
icon="message"
|
||||
title={t.newMessage}
|
||||
sub={dialog.pending[0]?.text.split("\n")[0].slice(0, 90) ?? ""}
|
||||
chip={t.open}
|
||||
onClick={() => navigate("/flow")}
|
||||
/>
|
||||
))}
|
||||
|
||||
{surveys && q.intake === "review" && <Notice>{t.intakeReview}</Notice>}
|
||||
|
||||
{effectRx && (
|
||||
<button type="button" className="effect" onClick={() => navigate(`/meds/${effectRx.ref_id}`)}>
|
||||
<span className="effect__day">
|
||||
<span className="effect__num">{effectRx.therapy_day}</span>
|
||||
<span className="effect__unit">день</span>
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{t.newMessage}</span>
|
||||
<span className="task__sub">{dialog.pending[0]?.text.split("\n")[0].slice(0, 90)}</span>
|
||||
<span className="task__title">{t.therapyDay(effectRx.therapy_day!)}</span>
|
||||
<span className="task__sub">{t.effectHint}</span>
|
||||
</span>
|
||||
<span className="chip chip--teal-solid">{t.open}</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -142,22 +224,36 @@ export function Today() {
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
))}
|
||||
{plan && !plan.checkin_done && (
|
||||
<button
|
||||
type="button"
|
||||
className="task"
|
||||
onClick={() => go("menu:checkin", t.checkin)}
|
||||
{intakeOpen && waiting?.kind !== "survey" && (
|
||||
<Task
|
||||
hero={!hasOpenStep && !doseNow}
|
||||
icon="clipboard"
|
||||
title={t.intakeStart}
|
||||
sub={q.intake === "none" ? t.intakeStartHint : t.intakeResumeHint}
|
||||
chip={q.intake === "none" ? undefined : t.continue}
|
||||
disabled={dialog.busy}
|
||||
>
|
||||
<span className="task__icon task__icon--teal">
|
||||
<Icon name="smile" />
|
||||
</span>
|
||||
<span className="task__text">
|
||||
<span className="task__title">{t.checkin}</span>
|
||||
<span className="task__sub">{t.checkinHint}</span>
|
||||
</span>
|
||||
<Icon name="chevronRight" />
|
||||
</button>
|
||||
onClick={() =>
|
||||
q.intake === "none" ? go("intake:start", t.intakeStart) : go("intake:resume", t.intakeResume)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{diaryDue && waiting?.kind !== "survey" && (
|
||||
<Task
|
||||
icon="book"
|
||||
title={t.diary}
|
||||
sub={t.diaryHint}
|
||||
disabled={dialog.busy}
|
||||
onClick={() => go("menu:checkin", t.diary)}
|
||||
/>
|
||||
)}
|
||||
{plan && !surveys && !plan.checkin_done && (
|
||||
<Task
|
||||
icon="smile"
|
||||
title={t.checkin}
|
||||
sub={t.checkinHint}
|
||||
disabled={dialog.busy}
|
||||
onClick={() => go("menu:checkin", t.checkin)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -190,7 +286,7 @@ export function Today() {
|
||||
<span className="badge-icon badge-icon--teal">
|
||||
<Icon name="check" size={16} />
|
||||
</span>
|
||||
<span className="done-row__title">{t.checkinDone}</span>
|
||||
<span className="done-row__title">{surveys ? t.diaryDone : t.checkinDone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -40,3 +40,9 @@ export function humanDate(iso: string): string {
|
||||
const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`;
|
||||
}
|
||||
|
||||
/** "4 октября" — from an ISO date. */
|
||||
export function dayMonth(iso: string): string {
|
||||
const [, m, d] = iso.split("-").map(Number);
|
||||
return `${d} ${MONTHS[m - 1]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user