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:
@@ -119,6 +119,12 @@ Share your medication schedule with others via a public link.
|
||||
</blockquote>
|
||||
</details>
|
||||
|
||||
### Medication Setup
|
||||
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`
|
||||
- Explicit review-and-apply flow with low-risk suggestions only
|
||||
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
||||
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
||||
|
||||
### Smart Inventory
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||
- Display remaining days of supply
|
||||
|
||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
@@ -29,6 +30,7 @@ import { reportRoutes } from "./routes/report.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
|
||||
|
||||
@@ -93,6 +95,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
{ name: "health", description: "Service health endpoints" },
|
||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||
{ name: "api-keys", description: "Programmatic API key management" },
|
||||
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||
],
|
||||
components: {
|
||||
@@ -206,6 +209,7 @@ export async function createApp(options?: {
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -287,6 +291,7 @@ await app.register(authRoutes);
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -307,6 +312,13 @@ const start = async () => {
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
startMedicationEnrichmentCatalogRefresh({
|
||||
info: (msg: string) => app.log.info(msg),
|
||||
debug: (msg: string) => app.log.debug(msg),
|
||||
warn: (msg: string) => app.log.warn(msg),
|
||||
error: (msg: string) => app.log.error(msg),
|
||||
});
|
||||
|
||||
// Start the intake reminder scheduler (checks every minute)
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import {
|
||||
enrichMedicationSelection,
|
||||
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
type MedicationEnrichmentEnrichRequest,
|
||||
MedicationEnrichmentServiceError,
|
||||
searchMedicationEnrichment,
|
||||
} from "../services/medication-enrichment.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1).max(120),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
|
||||
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
|
||||
});
|
||||
|
||||
const enrichBodySchema = z.object({
|
||||
query: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(140),
|
||||
genericName: z.string().trim().max(140).nullable().optional(),
|
||||
code: z.string().trim().min(1).max(160).nullable().optional(),
|
||||
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
|
||||
});
|
||||
|
||||
const searchQueryOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["q"],
|
||||
properties: {
|
||||
q: { type: "string", minLength: 1, maxLength: 120 },
|
||||
limit: {
|
||||
anyOf: [
|
||||
{ type: "string", pattern: "^[0-9]+$" },
|
||||
{
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["query", "name"],
|
||||
properties: {
|
||||
query: { type: "string", minLength: 1, maxLength: 120 },
|
||||
name: { type: "string", minLength: 1, maxLength: 140 },
|
||||
genericName: { type: "string", nullable: true, maxLength: 140 },
|
||||
code: { type: "string", nullable: true, maxLength: 160 },
|
||||
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const strengthOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
pillWeightMg: { type: "number", nullable: true },
|
||||
doseUnit: {
|
||||
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const searchResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
normalizedQuery: { type: "string" },
|
||||
hasMore: { type: "boolean" },
|
||||
results: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
authorisationHolder: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
matchType: { type: "string", enum: ["brand", "ingredient"] },
|
||||
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||
authorisationDate: { type: "string", nullable: true },
|
||||
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
selection: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
indication: { type: "string", nullable: true },
|
||||
atcCode: { type: "string", nullable: true },
|
||||
source: {
|
||||
type: "string",
|
||||
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
medicationForm: {
|
||||
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||
},
|
||||
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
type: "object",
|
||||
properties: {
|
||||
rxNormMatched: { type: "boolean" },
|
||||
openFdaMatched: { type: "boolean" },
|
||||
partial: { type: "boolean" },
|
||||
note: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function sendServiceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof MedicationEnrichmentServiceError) {
|
||||
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
|
||||
}
|
||||
|
||||
return reply.status(503).send({
|
||||
error: "Medication enrichment request failed.",
|
||||
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
|
||||
|
||||
app.get(
|
||||
"/medication-enrichment/search",
|
||||
{
|
||||
schema: {
|
||||
querystring: searchQueryOpenApiSchema,
|
||||
response: {
|
||||
200: searchResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = searchQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Search request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
|
||||
"/medication-enrichment/enrich",
|
||||
{
|
||||
schema: {
|
||||
body: enrichBodyOpenApiSchema,
|
||||
response: {
|
||||
200: enrichResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = enrichBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await enrichMedicationSelection(parsed.data, request.log);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Enrich request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { fetchMock, requireAuthMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
requireAuthMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function createEmaRow(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
category: "Human",
|
||||
medicine_status: "Authorised",
|
||||
name_of_medicine: "Aspirin 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetylsalicylic acid",
|
||||
active_substance: "Acetylsalicylic acid",
|
||||
marketing_authorisation_developer_applicant_holder: "Bayer",
|
||||
therapeutic_area_mesh: "Pain",
|
||||
therapeutic_indication: "Pain relief",
|
||||
atc_code_human: "N02BA01",
|
||||
generic_or_hybrid: "No",
|
||||
biosimilar: "No",
|
||||
marketing_authorisation_date: "01/02/2024",
|
||||
ema_product_number: "EMA-ASPIRIN",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js");
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("medication enrichment", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
requireAuthMock.mockReset();
|
||||
requireAuthMock.mockImplementation(async () => {});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("normalizes German ingredient queries for EMA-backed search results", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
createEmaRow({
|
||||
name_of_medicine: "Ibuprofen 400 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Ibuprofen",
|
||||
active_substance: "Ibuprofen",
|
||||
ema_product_number: "EMA-IBUPROFEN",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5);
|
||||
|
||||
expect(response.normalizedQuery).toBe("paracetamol 500 mg");
|
||||
expect(response.results).toHaveLength(1);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "EMA-TYLENOL",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
matchType: "ingredient",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires auth and returns EMA search results from the route", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=aspirin&limit=1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requireAuthMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.json()).toMatchObject({
|
||||
query: "aspirin",
|
||||
normalizedQuery: "aspirin",
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
source: "ema",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=semaglutide")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "12345",
|
||||
name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]",
|
||||
synonym: "Wegovy 0.25 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Semaglutide", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "12345",
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "00011-1111",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "openfda",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "1191",
|
||||
name: "Aspirin 500 MG Oral Tablet [Aspirin]",
|
||||
synonym: "Aspirin 500 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Bayer Aspirin",
|
||||
generic_name: "Acetylsalicylic acid",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Aspirin", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toHaveLength(3);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "1191",
|
||||
source: "rxnorm",
|
||||
});
|
||||
expect(response.results[1]).toMatchObject({
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
expect(response.results[2]).toMatchObject({
|
||||
code: "EMA-ASPIRIN",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates malformed search requests", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns enrichment suggestions with optional RxNorm strength data", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Acetaminophen 500 MG Oral Tablet" },
|
||||
{ name: "Acetaminophen 650 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
selection: {
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
source: "ema+rxnorm",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" },
|
||||
{ label: "650 mg", pillWeightMg: 650, doseUnit: "mg" },
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: false,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||
return Promise.reject(new Error("rxnorm timeout"));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
});
|
||||
|
||||
expect(response.selection.source).toBe("ema");
|
||||
expect(response.suggestions.strengthOptions).toEqual([]);
|
||||
expect(response.meta).toEqual({
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: false,
|
||||
partial: true,
|
||||
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/rxcui/12345/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Ozempic",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
code: "12345",
|
||||
source: "rxnorm",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("search=product_ndc%3A%2200011-1111%22")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "US Ibuprofen",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=ibuprofen&search=2")) {
|
||||
return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } }));
|
||||
}
|
||||
if (url.includes("/rxcui/161/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Ibuprofen 200 MG Oral Tablet" },
|
||||
{ name: "Ibuprofen 400 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "US Ibuprofen",
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not found when an explicit selection cannot be resolved", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Unknown",
|
||||
name: "Completely Different Medication",
|
||||
genericName: "No match",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toMatchObject({
|
||||
code: "MEDICATION_ENRICHMENT_NOT_FOUND",
|
||||
error: "Selected medication could not be resolved.",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -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