Files
PTSRaiAgent/web/src/screens/DrugInfo.tsx
T
Arxip222andClaude Opus 5.5 464b474abd 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>
2026-10-04 08:53:13 +03:00

226 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}