- 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>
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
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>
|
||
);
|
||
}
|