feat: add medication enrichment lookup to the medication editor
* feat: add medication enrichment lookup * fix: avoid double unescape in enrichment sanitization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MedicationEnrichmentEnrichResponse,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
} from "../types";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
|
||||
export interface MedicationEnrichmentViewModel {
|
||||
query: string;
|
||||
results: MedicationEnrichmentSearchResult[];
|
||||
hasMoreResults?: boolean;
|
||||
isSearching: boolean;
|
||||
hasSearched: boolean;
|
||||
searchError: string | null;
|
||||
applyingCode: string | null;
|
||||
activeResultCode: string | null;
|
||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||
enrichError: string | null;
|
||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
appliedStrengthLabel: string | null;
|
||||
}
|
||||
|
||||
export interface MedicationEnrichmentSectionProps {
|
||||
state: MedicationEnrichmentViewModel;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
onLoadMoreResults?: () => void;
|
||||
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
}
|
||||
|
||||
export function MedicationEnrichmentSection({
|
||||
state,
|
||||
onQueryChange,
|
||||
onSearch,
|
||||
onLoadMoreResults,
|
||||
onApplyResult,
|
||||
onApplyStrength,
|
||||
}: MedicationEnrichmentSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
||||
const shouldAutoExpand =
|
||||
state.isSearching ||
|
||||
state.hasSearched ||
|
||||
state.searchError !== null ||
|
||||
state.enrichError !== null ||
|
||||
state.results.length > 0 ||
|
||||
state.appliedSelection !== null ||
|
||||
state.strengthOptions.length > 0 ||
|
||||
state.appliedStrengthLabel !== null ||
|
||||
Boolean(state.meta?.partial);
|
||||
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
|
||||
const autoExpandStateRef = useRef(shouldAutoExpand);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
|
||||
autoExpandStateRef.current = shouldAutoExpand;
|
||||
}, [shouldAutoExpand]);
|
||||
|
||||
return (
|
||||
<div className="full medication-enrichment-section">
|
||||
<div className="medication-enrichment-header">
|
||||
<div>
|
||||
<h5 className="form-category-title medication-enrichment-title">{t("form.enrichment.title")}</h5>
|
||||
<p className="sub medication-enrichment-collapsed-hint">{t("form.enrichment.collapsedHint")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
{isExpanded ? t("form.enrichment.toggleHide") : t("form.enrichment.toggleShow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="medication-enrichment-body">
|
||||
<div className="medication-enrichment-helper-row">
|
||||
<span className="status-chip small warning">{t("form.enrichment.coverageLabel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost small"
|
||||
aria-expanded={showInfo}
|
||||
onClick={() => setShowInfo((current) => !current)}
|
||||
>
|
||||
{showInfo ? t("form.enrichment.infoHide") : t("form.enrichment.infoShow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showInfo ? (
|
||||
<div className="medication-enrichment-info">
|
||||
<p className="medication-enrichment-info-title">{t("form.enrichment.infoTitle")}</p>
|
||||
<p className="sub medication-enrichment-description">{t("form.enrichment.description")}</p>
|
||||
<p className="sub medication-enrichment-manual-hint">{t("form.enrichment.manualEntryHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="full">
|
||||
{t("form.enrichment.searchLabel")}
|
||||
<div className="medication-enrichment-search-row">
|
||||
<input
|
||||
type="search"
|
||||
value={state.query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (!canSearch) return;
|
||||
onSearch();
|
||||
}}
|
||||
placeholder={t("form.enrichment.searchPlaceholder")}
|
||||
/>
|
||||
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
|
||||
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
|
||||
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
|
||||
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
|
||||
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
|
||||
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 ? (
|
||||
<div className="medication-enrichment-results">
|
||||
{state.results.map((result) => {
|
||||
const isActive = state.activeResultCode === result.code;
|
||||
const hasDetails = Boolean(
|
||||
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
|
||||
);
|
||||
const isDetailsExpanded = expandedResultCode === result.code;
|
||||
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
|
||||
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
|
||||
let applyLabel = t("form.enrichment.applyAction");
|
||||
if (state.applyingCode === result.code) {
|
||||
applyLabel = t("form.enrichment.applying");
|
||||
} else if (isActive && state.appliedSelection) {
|
||||
applyLabel = t("form.enrichment.applied");
|
||||
}
|
||||
|
||||
return (
|
||||
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
|
||||
<div className="medication-enrichment-result-header">
|
||||
<div className="medication-enrichment-result-names">
|
||||
<strong>{result.name}</strong>
|
||||
{result.genericName ? (
|
||||
<span className="medication-enrichment-result-generic">{result.genericName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="medication-enrichment-result-actions">
|
||||
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
|
||||
{result.source === "ema" ? (
|
||||
<span className={`pill ${genericStatusClass}`}>
|
||||
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost small"
|
||||
aria-expanded={isDetailsExpanded}
|
||||
onClick={() =>
|
||||
setExpandedResultCode((current) => (current === result.code ? null : result.code))
|
||||
}
|
||||
>
|
||||
{isDetailsExpanded
|
||||
? t("form.enrichment.details.hideAction")
|
||||
: t("form.enrichment.details.showAction")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={isActive ? "secondary small" : "primary small"}
|
||||
onClick={() => {
|
||||
setExpandedResultCode(result.code);
|
||||
onApplyResult(result);
|
||||
}}
|
||||
disabled={state.applyingCode === result.code}
|
||||
>
|
||||
{applyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDetails && isDetailsExpanded ? (
|
||||
<dl className="medication-enrichment-result-meta">
|
||||
{result.authorisationHolder ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
||||
<dd>{result.authorisationHolder}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.therapeuticArea ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
||||
<dd>{result.therapeuticArea}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.authorisationDate ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
||||
<dd>{formatDate(result.authorisationDate)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
|
||||
<div className="medication-enrichment-results-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
onClick={onLoadMoreResults}
|
||||
disabled={state.isSearching || Boolean(state.applyingCode)}
|
||||
>
|
||||
{t("form.enrichment.showMoreAction")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
|
||||
<div className="medication-enrichment-followup">
|
||||
{state.appliedSelection ? (
|
||||
<div>
|
||||
<p className="success-text">{t("form.enrichment.applied")}</p>
|
||||
<p className="sub medication-enrichment-selection-summary">
|
||||
<strong>{state.appliedSelection.name}</strong>
|
||||
{state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.strengthOptions.length > 0 ? (
|
||||
<div className="medication-enrichment-strengths">
|
||||
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
|
||||
<p className="sub">{t("form.enrichment.strengthHint")}</p>
|
||||
<div className="medication-enrichment-strength-list">
|
||||
{state.strengthOptions.map((option) => {
|
||||
const isSelected = state.appliedStrengthLabel === option.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
className={isSelected ? "primary small" : "secondary small"}
|
||||
onClick={() => onApplyStrength(option)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.appliedStrengthLabel ? (
|
||||
<p className="success-text">
|
||||
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import type {
|
||||
DoseUnit,
|
||||
FieldErrors,
|
||||
FormBlister,
|
||||
FormIntake,
|
||||
FormState,
|
||||
Medication,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
} from "../types";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
DOSE_UNITS,
|
||||
@@ -28,6 +37,8 @@ import {
|
||||
} from "../utils/intake-schedule";
|
||||
import { DateInput } from "./DateInput";
|
||||
import { FormNumberStepper } from "./FormNumberStepper";
|
||||
import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||
import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -40,11 +51,33 @@ const FIELD_LIMITS = {
|
||||
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
||||
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
|
||||
|
||||
const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
|
||||
query: "",
|
||||
results: [],
|
||||
hasMoreResults: false,
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
};
|
||||
|
||||
export interface MobileEditModalProps {
|
||||
show: boolean;
|
||||
editingId: number | null;
|
||||
form: FormState;
|
||||
onFormChange: (form: FormState) => void;
|
||||
medicationEnrichment?: MedicationEnrichmentViewModel;
|
||||
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
||||
onMedicationEnrichmentSearch?: () => void;
|
||||
onMedicationEnrichmentLoadMore?: () => void;
|
||||
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
fieldErrors: FieldErrors;
|
||||
saving: boolean;
|
||||
formSaved: boolean;
|
||||
@@ -97,6 +130,12 @@ export function MobileEditModal({
|
||||
editingId,
|
||||
form,
|
||||
onFormChange,
|
||||
medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT,
|
||||
onMedicationEnrichmentQueryChange = () => {},
|
||||
onMedicationEnrichmentSearch = () => {},
|
||||
onMedicationEnrichmentLoadMore = () => {},
|
||||
onMedicationEnrichmentApply = () => {},
|
||||
onMedicationEnrichmentStrengthApply = () => {},
|
||||
fieldErrors,
|
||||
saving,
|
||||
formSaved,
|
||||
@@ -446,6 +485,14 @@ export function MobileEditModal({
|
||||
<span className="field-error">{fieldErrors.genericName}</span>
|
||||
)}
|
||||
</label>
|
||||
<MedicationEnrichmentSection
|
||||
state={medicationEnrichment}
|
||||
onQueryChange={onMedicationEnrichmentQueryChange}
|
||||
onSearch={onMedicationEnrichmentSearch}
|
||||
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
||||
onApplyResult={onMedicationEnrichmentApply}
|
||||
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
{t("form.medicationStartDate")}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export { PasswordInput } from "./PasswordInput";
|
||||
|
||||
@@ -225,6 +225,50 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"enrichment": {
|
||||
"title": "Optionale Medikamentensuche",
|
||||
"coverageLabel": "Unvollständige freie Abdeckung",
|
||||
"collapsedHint": "Öffne das nur, wenn du Suchvorschläge nutzen möchtest.",
|
||||
"toggleShow": "Suche anzeigen",
|
||||
"toggleHide": "Suche ausblenden",
|
||||
"infoShow": "Infos zu den Quellen",
|
||||
"infoHide": "Quellenhinweise ausblenden",
|
||||
"infoTitle": "Was du erwarten kannst",
|
||||
"description": "Durchsuche zuerst RxNorm und openFDA, nutze EMA nur als letzten Fallback und prüfe jeden Treffer, bevor du etwas ins Formular übernimmst.",
|
||||
"searchLabel": "Medikamentenquellen durchsuchen",
|
||||
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
|
||||
"searchAction": "Suchen",
|
||||
"searching": "Suche läuft...",
|
||||
"showMoreAction": "Mehr Treffer anzeigen",
|
||||
"noResults": "Es wurden in der aktuellen Freiquellen-Suche keine Treffer gefunden. Du kannst das Medikament manuell weiter erfassen.",
|
||||
"manualEntryHint": "Diese Hilfe ist optional und kann Medikamente, Stärken oder lokale Marktvarianten übersehen.",
|
||||
"searchError": "Die Medikamentensuche ist momentan nicht verfügbar. Bitte fahre mit der manuellen Eingabe fort.",
|
||||
"applyAction": "Übernehmen",
|
||||
"applying": "Wird übernommen...",
|
||||
"applied": "Ins Formular übernommen",
|
||||
"applyError": "Das Autofill konnte nicht übernommen werden. Bitte bearbeite das Medikament manuell weiter.",
|
||||
"partialNote": "Es waren nur teilweise Autofill-Vorschläge verfügbar. Prüfe die Felder vor dem Speichern.",
|
||||
"strengthTitle": "Stärke-Vorschläge",
|
||||
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
|
||||
"appliedStrength": "Übernommene Stärke: {{label}}",
|
||||
"details": {
|
||||
"showAction": "Mehr Details",
|
||||
"hideAction": "Weniger Details",
|
||||
"authorisationHolder": "Zulassungsinhaber",
|
||||
"therapeuticArea": "Therapiebereich",
|
||||
"authorisationDate": "Zulassungsdatum"
|
||||
},
|
||||
"genericStatus": {
|
||||
"generic": "Generikum",
|
||||
"original": "Original",
|
||||
"unknown": "Status unbekannt"
|
||||
},
|
||||
"sources": {
|
||||
"ema": "EMA",
|
||||
"rxnorm": "RxNorm",
|
||||
"openfda": "openFDA (USA)"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
||||
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
||||
|
||||
@@ -225,6 +225,50 @@
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"enrichment": {
|
||||
"title": "Optional medication lookup",
|
||||
"coverageLabel": "Incomplete free-source coverage",
|
||||
"collapsedHint": "Open this only if you want lookup suggestions.",
|
||||
"toggleShow": "Show lookup",
|
||||
"toggleHide": "Hide lookup",
|
||||
"infoShow": "About sources",
|
||||
"infoHide": "Hide source notes",
|
||||
"infoTitle": "What to expect",
|
||||
"description": "Search RxNorm and openFDA first, use EMA as a last fallback, and review each result before applying anything to the form.",
|
||||
"searchLabel": "Search medication sources",
|
||||
"searchPlaceholder": "Search by brand or ingredient",
|
||||
"searchAction": "Search",
|
||||
"searching": "Searching...",
|
||||
"showMoreAction": "Show more results",
|
||||
"noResults": "No matches were found in the current free-source search. You can continue entering the medication manually.",
|
||||
"manualEntryHint": "This helper is optional and may miss medications, strengths, or local market variants.",
|
||||
"searchError": "Medication lookup is currently unavailable. Please continue with manual entry.",
|
||||
"applyAction": "Apply",
|
||||
"applying": "Applying...",
|
||||
"applied": "Applied to form",
|
||||
"applyError": "Autofill could not be applied. Please keep editing the medication manually.",
|
||||
"partialNote": "Only partial autofill suggestions were available. Review the fields before saving.",
|
||||
"strengthTitle": "Strength suggestions",
|
||||
"strengthHint": "Choose a strength to update dose per pill and unit.",
|
||||
"appliedStrength": "Applied strength: {{label}}",
|
||||
"details": {
|
||||
"showAction": "More details",
|
||||
"hideAction": "Less details",
|
||||
"authorisationHolder": "Authorisation holder",
|
||||
"therapeuticArea": "Therapeutic area",
|
||||
"authorisationDate": "Authorisation date"
|
||||
},
|
||||
"genericStatus": {
|
||||
"generic": "Generic",
|
||||
"original": "Original",
|
||||
"unknown": "Status unknown"
|
||||
},
|
||||
"sources": {
|
||||
"ema": "EMA",
|
||||
"rxnorm": "RxNorm",
|
||||
"openfda": "openFDA (US)"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
||||
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
||||
|
||||
@@ -11,13 +11,23 @@ import {
|
||||
FormNumberStepper,
|
||||
Lightbox,
|
||||
MedicationAvatar,
|
||||
MedicationEnrichmentSection,
|
||||
MobileEditModal,
|
||||
ReportModal,
|
||||
} from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, FormState, Medication, PackageType } from "../types";
|
||||
import type {
|
||||
DoseUnit,
|
||||
FormState,
|
||||
Medication,
|
||||
MedicationEnrichmentEnrichResponse,
|
||||
MedicationEnrichmentSearchResponse,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
PackageType,
|
||||
} from "../types";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
DOSE_UNITS,
|
||||
@@ -49,6 +59,113 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
}
|
||||
|
||||
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
||||
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
|
||||
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
|
||||
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
|
||||
|
||||
type MedicationEnrichmentState = {
|
||||
query: string;
|
||||
results: MedicationEnrichmentSearchResult[];
|
||||
hasMoreResults: boolean;
|
||||
resultLimit: number;
|
||||
isSearching: boolean;
|
||||
hasSearched: boolean;
|
||||
searchError: string | null;
|
||||
applyingCode: string | null;
|
||||
activeResultCode: string | null;
|
||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||
enrichError: string | null;
|
||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
appliedStrengthLabel: string | null;
|
||||
};
|
||||
|
||||
function createMedicationEnrichmentState(
|
||||
query = "",
|
||||
resultLimit = MEDICATION_ENRICHMENT_INITIAL_LIMIT
|
||||
): MedicationEnrichmentState {
|
||||
return {
|
||||
query,
|
||||
results: [],
|
||||
hasMoreResults: false,
|
||||
resultLimit,
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
|
||||
if (unit === "IU") return "units";
|
||||
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMedicationEnrichmentSuggestions(
|
||||
form: FormState,
|
||||
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
|
||||
): FormState {
|
||||
const nextForm: FormState = {
|
||||
...form,
|
||||
name: suggestions.name,
|
||||
genericName: suggestions.genericName ?? "",
|
||||
};
|
||||
|
||||
if (suggestions.medicationForm === "tablet" || suggestions.medicationForm === "capsule") {
|
||||
return {
|
||||
...nextForm,
|
||||
medicationForm: suggestions.medicationForm,
|
||||
pillForm: suggestions.medicationForm,
|
||||
};
|
||||
}
|
||||
|
||||
if (suggestions.medicationForm === "liquid" || suggestions.medicationForm === "topical") {
|
||||
return {
|
||||
...nextForm,
|
||||
medicationForm: suggestions.medicationForm,
|
||||
};
|
||||
}
|
||||
|
||||
return nextForm;
|
||||
}
|
||||
|
||||
function applyMedicationEnrichmentStrength(
|
||||
form: FormState,
|
||||
option: MedicationEnrichmentStrengthOption
|
||||
): FormState | null {
|
||||
if (option.pillWeightMg === null) return null;
|
||||
const doseUnit = normalizeMedicationEnrichmentDoseUnit(option.doseUnit);
|
||||
if (!doseUnit) return null;
|
||||
|
||||
return {
|
||||
...form,
|
||||
pillWeightMg: `${option.pillWeightMg}`,
|
||||
doseUnit,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const errorBody = (await response.json()) as { error?: string; message?: string };
|
||||
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
|
||||
return errorBody.error;
|
||||
}
|
||||
if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) {
|
||||
return errorBody.message;
|
||||
}
|
||||
} catch {
|
||||
// keep translated fallback
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function MedicationsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -162,6 +279,88 @@ export function MedicationsPage() {
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
|
||||
createMedicationEnrichmentState()
|
||||
);
|
||||
|
||||
const resetMedicationEnrichment = useCallback((query = "") => {
|
||||
setMedicationEnrichment(createMedicationEnrichmentState(query));
|
||||
}, []);
|
||||
|
||||
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
query: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const performMedicationEnrichmentSearch = useCallback(
|
||||
async (requestedLimit: number, preserveExistingResults = false) => {
|
||||
const trimmedQuery = medicationEnrichment.query.trim();
|
||||
if (!trimmedQuery) return;
|
||||
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
|
||||
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
query: trimmedQuery,
|
||||
results: preserveExistingResults ? previous.results : [],
|
||||
hasMoreResults: false,
|
||||
resultLimit: limit,
|
||||
isSearching: true,
|
||||
hasSearched: preserveExistingResults ? previous.hasSearched : false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
...(preserveExistingResults
|
||||
? {}
|
||||
: {
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
|
||||
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
|
||||
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
query: data.query,
|
||||
results: Array.isArray(data.results) ? data.results : [],
|
||||
hasMoreResults: Boolean(data.hasMore),
|
||||
resultLimit: limit,
|
||||
isSearching: false,
|
||||
hasSearched: true,
|
||||
searchError: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
|
||||
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
results: preserveExistingResults ? previous.results : [],
|
||||
hasMoreResults: false,
|
||||
resultLimit: limit,
|
||||
isSearching: false,
|
||||
hasSearched: true,
|
||||
searchError: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[medicationEnrichment.query, t]
|
||||
);
|
||||
|
||||
const handlePendingMedicationImageSelection = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -258,6 +457,126 @@ export function MedicationsPage() {
|
||||
[deleteMedImage, loadAllMeds]
|
||||
);
|
||||
|
||||
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
|
||||
if (!allowsPillFormSelection(form.packageType)) return [];
|
||||
|
||||
return medicationEnrichment.strengthOptions.filter(
|
||||
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
||||
);
|
||||
}, [form.packageType, medicationEnrichment.strengthOptions]);
|
||||
|
||||
const handleMedicationEnrichmentSearch = useCallback(async () => {
|
||||
await performMedicationEnrichmentSearch(MEDICATION_ENRICHMENT_INITIAL_LIMIT);
|
||||
}, [performMedicationEnrichmentSearch]);
|
||||
|
||||
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
|
||||
if (medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults) return;
|
||||
await performMedicationEnrichmentSearch(
|
||||
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||
true
|
||||
);
|
||||
}, [
|
||||
medicationEnrichment.hasMoreResults,
|
||||
medicationEnrichment.isSearching,
|
||||
medicationEnrichment.resultLimit,
|
||||
performMedicationEnrichmentSearch,
|
||||
]);
|
||||
|
||||
const handleMedicationEnrichmentApply = useCallback(
|
||||
async (result: MedicationEnrichmentSearchResult) => {
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: result.code,
|
||||
activeResultCode: result.code,
|
||||
enrichError: null,
|
||||
appliedSelection: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/medication-enrichment/enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: medicationEnrichment.query.trim() || result.name,
|
||||
name: result.name,
|
||||
genericName: result.genericName ?? null,
|
||||
code: result.code,
|
||||
source: result.source,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
|
||||
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
|
||||
let appliedStrengthLabel: string | null = null;
|
||||
|
||||
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
|
||||
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
|
||||
if (autoAppliedForm) {
|
||||
nextForm = autoAppliedForm;
|
||||
appliedStrengthLabel = data.suggestions.strengthOptions[0].label;
|
||||
}
|
||||
}
|
||||
|
||||
setForm(nextForm);
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: null,
|
||||
activeResultCode: result.code,
|
||||
appliedSelection: data.selection,
|
||||
enrichError: null,
|
||||
meta: data.meta,
|
||||
strengthOptions: data.suggestions.strengthOptions,
|
||||
appliedStrengthLabel,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.applyError");
|
||||
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: message,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[form, medicationEnrichment.query, setForm, t]
|
||||
);
|
||||
|
||||
const handleMedicationEnrichmentStrengthApply = useCallback(
|
||||
(option: MedicationEnrichmentStrengthOption) => {
|
||||
setForm((currentForm) => {
|
||||
const nextForm = applyMedicationEnrichmentStrength(currentForm, option);
|
||||
return nextForm ?? currentForm;
|
||||
});
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
appliedStrengthLabel: option.label,
|
||||
}));
|
||||
},
|
||||
[setForm]
|
||||
);
|
||||
|
||||
const medicationEnrichmentViewModel = useMemo(
|
||||
() => ({
|
||||
...medicationEnrichment,
|
||||
strengthOptions: applicableMedicationEnrichmentStrengthOptions,
|
||||
}),
|
||||
[applicableMedicationEnrichmentStrengthOptions, medicationEnrichment]
|
||||
);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
if (isAmountBasedPackageType(form.packageType)) {
|
||||
@@ -416,12 +735,14 @@ export function MedicationsPage() {
|
||||
if (pendingAction) {
|
||||
// There's a pending action (e.g. switching to another medication) — reset and run it
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setReadOnlyView(false);
|
||||
pendingAction();
|
||||
} else if (source === "mobile-edit" && showEditModal) {
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setReadOnlyView(false);
|
||||
window.history.back();
|
||||
} else {
|
||||
@@ -449,6 +770,7 @@ export function MedicationsPage() {
|
||||
window.history.back();
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setActiveTab("general");
|
||||
setReadOnlyView(false);
|
||||
@@ -710,11 +1032,13 @@ export function MedicationsPage() {
|
||||
setActiveTab("general");
|
||||
setViewMode("grid");
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
window.history.back();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setViewMode("grid");
|
||||
} else {
|
||||
// Update originalForm so formChanged becomes false
|
||||
@@ -758,6 +1082,7 @@ export function MedicationsPage() {
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -780,6 +1105,7 @@ export function MedicationsPage() {
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -793,6 +1119,7 @@ export function MedicationsPage() {
|
||||
}
|
||||
hasDesktopFormHistoryState.current = false;
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setActiveTab("general");
|
||||
setReadOnlyView(false);
|
||||
@@ -836,6 +1163,7 @@ export function MedicationsPage() {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -847,6 +1175,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -857,6 +1186,7 @@ export function MedicationsPage() {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -868,6 +1198,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -877,6 +1208,7 @@ export function MedicationsPage() {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -890,6 +1222,7 @@ export function MedicationsPage() {
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -932,6 +1265,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -1280,6 +1614,14 @@ export function MedicationsPage() {
|
||||
<span className="field-error">{fieldErrors.genericName}</span>
|
||||
)}
|
||||
</label>
|
||||
<MedicationEnrichmentSection
|
||||
state={medicationEnrichmentViewModel}
|
||||
onQueryChange={handleMedicationEnrichmentQueryChange}
|
||||
onSearch={handleMedicationEnrichmentSearch}
|
||||
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
||||
onApplyResult={handleMedicationEnrichmentApply}
|
||||
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
{t("form.medicationStartDate")}
|
||||
@@ -1938,6 +2280,12 @@ export function MedicationsPage() {
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
medicationEnrichment={medicationEnrichmentViewModel}
|
||||
onMedicationEnrichmentQueryChange={handleMedicationEnrichmentQueryChange}
|
||||
onMedicationEnrichmentSearch={handleMedicationEnrichmentSearch}
|
||||
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
||||
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
||||
fieldErrors={fieldErrors}
|
||||
saving={saving}
|
||||
formSaved={formSaved}
|
||||
|
||||
@@ -2068,6 +2068,211 @@ button.has-validation-error {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.medication-enrichment-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 62%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.medication-enrichment-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-title {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-collapsed-hint,
|
||||
.medication-enrichment-description,
|
||||
.medication-enrichment-manual-hint,
|
||||
.medication-enrichment-selection-summary {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-helper-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--info) 28%, var(--border-primary));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent-bg) 55%, transparent);
|
||||
}
|
||||
|
||||
.medication-enrichment-info-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.medication-enrichment-search-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-search-row button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.medication-enrichment-results {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-results-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.medication-enrichment-result {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 68%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.medication-enrichment-result.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-primary));
|
||||
box-shadow: inset 0 0 0 1px rgba(47, 134, 246, 0.18);
|
||||
}
|
||||
|
||||
.medication-enrichment-result-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-names strong {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-generic {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta dt {
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.medication-enrichment-followup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--border-secondary);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
|
||||
}
|
||||
|
||||
.medication-enrichment-strengths {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-strength-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.medication-enrichment-strength-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-strength-list button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.medication-enrichment-header,
|
||||
.medication-enrichment-result-header,
|
||||
.medication-enrichment-search-row,
|
||||
.medication-enrichment-helper-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.medication-enrichment-search-row button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.select-field.dose-unit-select:hover,
|
||||
.dose-unit-select:hover {
|
||||
border-color: var(--accent);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MedicationEnrichmentSection,
|
||||
type MedicationEnrichmentViewModel,
|
||||
} from "../../components/MedicationEnrichmentSection";
|
||||
import type { MedicationEnrichmentSearchResult, MedicationEnrichmentStrengthOption } from "../../types";
|
||||
|
||||
function createResult(overrides: Partial<MedicationEnrichmentSearchResult> = {}): MedicationEnrichmentSearchResult {
|
||||
return {
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: "Bayer",
|
||||
therapeuticArea: "Pain",
|
||||
matchType: "brand",
|
||||
genericStatus: "original",
|
||||
authorisationDate: "2024-02-01",
|
||||
source: "ema",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStrengthOption(
|
||||
overrides: Partial<MedicationEnrichmentStrengthOption> = {}
|
||||
): MedicationEnrichmentStrengthOption {
|
||||
return {
|
||||
label: "500 mg",
|
||||
pillWeightMg: 500,
|
||||
doseUnit: "mg",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<MedicationEnrichmentViewModel> = {}): MedicationEnrichmentViewModel {
|
||||
return {
|
||||
query: "",
|
||||
results: [],
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MedicationEnrichmentSection", () => {
|
||||
it("starts collapsed so the lookup stays optional by default", () => {
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState()}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("form.enrichment.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports explicit show and hide toggles for the lookup and source guidance", () => {
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState()}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
|
||||
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
|
||||
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.infoHide" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoHide" }));
|
||||
expect(screen.queryByText("form.enrichment.infoTitle")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.infoShow" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleHide" }));
|
||||
expect(screen.queryByPlaceholderText("form.enrichment.searchPlaceholder")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals guidance only when requested and wires search/apply actions", () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onSearch = vi.fn();
|
||||
const onApplyResult = vi.fn();
|
||||
const result = createResult();
|
||||
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState({ query: "Aspirin", results: [result] })}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearch={onSearch}
|
||||
onApplyResult={onApplyResult}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("form.enrichment.details.authorisationHolder")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.infoShow" }));
|
||||
expect(screen.getByText("form.enrichment.infoTitle")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.description")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.manualEntryHint")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||
target: { value: "Ibuprofen" },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.details.showAction" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
||||
|
||||
expect(onQueryChange).toHaveBeenCalledWith("Ibuprofen");
|
||||
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onApplyResult).toHaveBeenCalledWith(result);
|
||||
expect(screen.getByText("form.enrichment.details.authorisationHolder")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.details.therapeuticArea")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.genericStatus.original")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels RxNorm and openFDA results with their source badges", () => {
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState({
|
||||
query: "Semaglutide",
|
||||
results: [
|
||||
createResult({
|
||||
code: "RX-123",
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
createResult({
|
||||
code: "NDC-123",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "openfda",
|
||||
}),
|
||||
],
|
||||
})}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("form.enrichment.sources.rxnorm")).toBeInTheDocument();
|
||||
const openFdaBadge = screen.getByText("form.enrichment.sources.openfda");
|
||||
expect(openFdaBadge).toBeInTheDocument();
|
||||
expect(openFdaBadge).toHaveClass("warning");
|
||||
expect(screen.queryByText("form.enrichment.genericStatus.unknown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a load-more action when the backend reports more results", () => {
|
||||
const onLoadMoreResults = vi.fn();
|
||||
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState({
|
||||
query: "Aspirin",
|
||||
results: [createResult({ source: "rxnorm", code: "RX-123", name: "Aspirin" })],
|
||||
hasMoreResults: true,
|
||||
})}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onLoadMoreResults={onLoadMoreResults}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
|
||||
|
||||
expect(onLoadMoreResults).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can expand automatically when follow-up feedback exists", () => {
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState({
|
||||
hasSearched: true,
|
||||
searchError: "Lookup unavailable",
|
||||
})}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("form.enrichment.searchPlaceholder")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lookup unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows partial coverage feedback and optional strength suggestions", () => {
|
||||
const onApplyStrength = vi.fn();
|
||||
const strengthOption = createStrengthOption();
|
||||
|
||||
render(
|
||||
<MedicationEnrichmentSection
|
||||
state={createState({
|
||||
hasSearched: true,
|
||||
appliedSelection: {
|
||||
name: "Aspirin 500 mg tablets",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
therapeuticArea: "Pain",
|
||||
indication: "Pain relief",
|
||||
atcCode: "N02BA01",
|
||||
source: "ema",
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: false,
|
||||
partial: true,
|
||||
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||
},
|
||||
strengthOptions: [strengthOption],
|
||||
appliedStrengthLabel: "500 mg",
|
||||
})}
|
||||
onQueryChange={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onApplyResult={vi.fn()}
|
||||
onApplyStrength={onApplyStrength}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("form.enrichment.partialNote")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.applied")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.enrichment.strengthTitle")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "500 mg" }));
|
||||
|
||||
expect(onApplyStrength).toHaveBeenCalledWith(strengthOption);
|
||||
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { FormEvent } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MedicationEnrichmentViewModel } from "../../components/MedicationEnrichmentSection";
|
||||
import { MobileEditModal } from "../../components/MobileEditModal";
|
||||
import type { FormState, WeekdayCode } from "../../types";
|
||||
|
||||
@@ -92,6 +93,26 @@ const defaultProps = {
|
||||
onSaveMedication: vi.fn(),
|
||||
};
|
||||
|
||||
function createMedicationEnrichmentState(
|
||||
overrides: Partial<MedicationEnrichmentViewModel> = {}
|
||||
): MedicationEnrichmentViewModel {
|
||||
return {
|
||||
query: "",
|
||||
results: [],
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MobileEditModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -161,6 +182,64 @@ describe("MobileEditModal", () => {
|
||||
expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the shared medication enrichment section after generic name", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const genericNameLabel = screen.getByText("form.genericName");
|
||||
const enrichmentTitle = screen.getByText("form.enrichment.title");
|
||||
|
||||
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wires medication enrichment search and apply actions inside the mobile editor", () => {
|
||||
const onMedicationEnrichmentQueryChange = vi.fn();
|
||||
const onMedicationEnrichmentSearch = vi.fn();
|
||||
const onMedicationEnrichmentApply = vi.fn();
|
||||
const onMedicationEnrichmentStrengthApply = vi.fn();
|
||||
const result = {
|
||||
code: "RX-123",
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "brand" as const,
|
||||
genericStatus: "unknown" as const,
|
||||
authorisationDate: null,
|
||||
source: "rxnorm" as const,
|
||||
};
|
||||
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
|
||||
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
medicationEnrichment={createMedicationEnrichmentState({
|
||||
query: "Wegovy",
|
||||
results: [result],
|
||||
strengthOptions: [strengthOption],
|
||||
})}
|
||||
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
|
||||
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
|
||||
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleHide" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||
target: { value: "Ozempic" },
|
||||
});
|
||||
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "0.25 mg" }));
|
||||
|
||||
expect(onMedicationEnrichmentQueryChange).toHaveBeenCalledWith("Ozempic");
|
||||
expect(onMedicationEnrichmentSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result);
|
||||
expect(onMedicationEnrichmentStrengthApply).toHaveBeenCalledWith(strengthOption);
|
||||
});
|
||||
|
||||
it("groups medication start and end date fields in one stacked date pair", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
|
||||
@@ -492,4 +492,158 @@ describe("MedicationsPage form interactions", () => {
|
||||
expect(resetForm).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
|
||||
});
|
||||
|
||||
it("renders the shared medication enrichment section after generic name on desktop", () => {
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
|
||||
const genericNameLabel = screen.getByText("form.genericName");
|
||||
const enrichmentTitle = screen.getByText("form.enrichment.title");
|
||||
|
||||
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches and applies medication enrichment suggestions through the desktop form", async () => {
|
||||
const setForm = vi.fn();
|
||||
mockFormHookValue = createMockFormHook({ setForm });
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.startsWith("/api/medication-enrichment/search?")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: "Aspirin",
|
||||
normalizedQuery: "aspirin",
|
||||
hasMore: url.includes("limit=6"),
|
||||
results: [
|
||||
{
|
||||
code: "RX-ASPIRIN",
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "ingredient",
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: null,
|
||||
source: "rxnorm",
|
||||
},
|
||||
{
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: "Bayer",
|
||||
therapeuticArea: "Pain",
|
||||
matchType: "brand",
|
||||
genericStatus: "original",
|
||||
authorisationDate: "2024-02-01",
|
||||
source: "ema",
|
||||
},
|
||||
...(url.includes("limit=12")
|
||||
? [
|
||||
{
|
||||
code: "NDC-ASPIRIN",
|
||||
name: "Bayer Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "brand",
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: null,
|
||||
source: "openfda",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/medication-enrichment/enrich") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
selection: {
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
therapeuticArea: "Pain",
|
||||
indication: "Pain relief",
|
||||
atcCode: "N02BA01",
|
||||
source: "rxnorm",
|
||||
},
|
||||
suggestions: {
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: false,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||
target: { value: " Aspirin " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
|
||||
credentials: "include",
|
||||
});
|
||||
});
|
||||
|
||||
await screen.findByText("Aspirin 500 mg tablets");
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
|
||||
credentials: "include",
|
||||
});
|
||||
});
|
||||
|
||||
await screen.findByText("Bayer Aspirin");
|
||||
expect(screen.queryByRole("button", { name: "form.enrichment.showMoreAction" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: "Aspirin",
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
code: "RX-ASPIRIN",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
expect(setForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
medicationForm: "tablet",
|
||||
pillForm: "tablet",
|
||||
pillWeightMg: "500",
|
||||
doseUnit: "mg",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,64 @@ export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
|
||||
export type PillForm = "tablet" | "capsule";
|
||||
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
||||
export type PackageAmountUnit = "ml" | "g";
|
||||
export type MedicationEnrichmentDoseUnit = DoseUnit | "IU" | "drops" | "puffs";
|
||||
export type MedicationEnrichmentMatchType = "brand" | "ingredient";
|
||||
export type MedicationEnrichmentGenericStatus = "generic" | "original" | "unknown";
|
||||
export type MedicationEnrichmentSearchSource = "ema" | "rxnorm" | "openfda";
|
||||
export type MedicationEnrichmentSource =
|
||||
| MedicationEnrichmentSearchSource
|
||||
| "ema+rxnorm"
|
||||
| "ema+openfda"
|
||||
| "rxnorm+openfda"
|
||||
| "ema+rxnorm+openfda";
|
||||
|
||||
export type MedicationEnrichmentSearchResult = {
|
||||
code: string;
|
||||
name: string;
|
||||
genericName: string | null;
|
||||
authorisationHolder: string | null;
|
||||
therapeuticArea: string | null;
|
||||
matchType: MedicationEnrichmentMatchType;
|
||||
genericStatus: MedicationEnrichmentGenericStatus;
|
||||
authorisationDate: string | null;
|
||||
source: MedicationEnrichmentSearchSource;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentSearchResponse = {
|
||||
query: string;
|
||||
normalizedQuery: string;
|
||||
hasMore: boolean;
|
||||
results: MedicationEnrichmentSearchResult[];
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentStrengthOption = {
|
||||
label: string;
|
||||
pillWeightMg: number | null;
|
||||
doseUnit: MedicationEnrichmentDoseUnit | null;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentEnrichResponse = {
|
||||
selection: {
|
||||
name: string;
|
||||
genericName: string | null;
|
||||
therapeuticArea: string | null;
|
||||
indication: string | null;
|
||||
atcCode: string | null;
|
||||
source: MedicationEnrichmentSource;
|
||||
};
|
||||
suggestions: {
|
||||
name: string;
|
||||
genericName: string | null;
|
||||
medicationForm: MedicationForm | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
};
|
||||
meta: {
|
||||
rxNormMatched: boolean;
|
||||
openFdaMatched: boolean;
|
||||
partial: boolean;
|
||||
note: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||
{ value: "mg", label: "mg" },
|
||||
|
||||
Reference in New Issue
Block a user