@@ -120,7 +120,7 @@ Share your medication schedule with others via a public link.
|
||||
</details>
|
||||
|
||||
### Medication Setup
|
||||
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`
|
||||
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them
|
||||
- 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
|
||||
|
||||
@@ -75,6 +75,24 @@ const strengthOptionSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const packageOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
description: { type: "string" },
|
||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
|
||||
packCount: { type: "integer", minimum: 1 },
|
||||
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||
totalPills: { type: "integer", minimum: 0, nullable: true },
|
||||
looseTablets: { type: "integer", minimum: 0, nullable: true },
|
||||
packageAmountValue: { type: "integer", minimum: 1, nullable: true },
|
||||
packageAmountUnit: {
|
||||
anyOf: [{ type: "string", enum: ["ml", "g"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const searchResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -95,6 +113,7 @@ const searchResponseSchema = {
|
||||
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||
authorisationDate: { type: "string", nullable: true },
|
||||
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||
packageOptions: { type: "array", items: packageOptionSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,6 +146,7 @@ const enrichResponseSchema = {
|
||||
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||
},
|
||||
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||
packageOptions: { type: "array", items: packageOptionSchema },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { PackageType } from "../utils/package-profiles.js";
|
||||
|
||||
const EMA_MEDICINES_URL =
|
||||
"https://www.ema.europa.eu/en/documents/report/medicines-output-medicines_json-report_en.json";
|
||||
@@ -40,6 +41,7 @@ export type MedicationEnrichmentSearchResult = {
|
||||
genericStatus: "generic" | "original" | "unknown";
|
||||
authorisationDate: string | null;
|
||||
source: MedicationEnrichmentSearchSource;
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentStrengthOption = {
|
||||
@@ -48,6 +50,19 @@ export type MedicationEnrichmentStrengthOption = {
|
||||
doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | null;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentPackageOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
packageType: PackageType;
|
||||
packCount: number;
|
||||
blistersPerPack: number | null;
|
||||
pillsPerBlister: number | null;
|
||||
totalPills: number | null;
|
||||
looseTablets: number | null;
|
||||
packageAmountValue: number | null;
|
||||
packageAmountUnit: "ml" | "g" | null;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentSearchResponse = {
|
||||
query: string;
|
||||
normalizedQuery: string;
|
||||
@@ -77,6 +92,7 @@ export type MedicationEnrichmentEnrichResponse = {
|
||||
genericName: string | null;
|
||||
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
};
|
||||
meta: {
|
||||
rxNormMatched: boolean;
|
||||
@@ -161,6 +177,12 @@ type OpenFdaProduct = {
|
||||
dosage_form?: string;
|
||||
marketing_start_date?: string;
|
||||
active_ingredients?: OpenFdaActiveIngredient[];
|
||||
packaging?: OpenFdaPackaging[];
|
||||
};
|
||||
|
||||
type OpenFdaPackaging = {
|
||||
description?: string;
|
||||
package_ndc?: string;
|
||||
};
|
||||
|
||||
type OpenFdaResponse = {
|
||||
@@ -172,6 +194,14 @@ type OpenFdaEnrichment = {
|
||||
genericName: string | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
};
|
||||
|
||||
type ParsedOpenFdaPackagingSegment = {
|
||||
quantity: number;
|
||||
itemText: string;
|
||||
containerCount: number;
|
||||
containerText: string;
|
||||
};
|
||||
|
||||
const defaultLogger: MedicationEnrichmentLogger = {
|
||||
@@ -436,6 +466,7 @@ function compareSearchResults(
|
||||
right: MedicationEnrichmentSearchResult & { score: number }
|
||||
): number {
|
||||
return (
|
||||
right.packageOptions.length - left.packageOptions.length ||
|
||||
getSearchSourcePriority(left.source) - getSearchSourcePriority(right.source) ||
|
||||
right.score - left.score ||
|
||||
left.name.localeCompare(right.name)
|
||||
@@ -474,6 +505,7 @@ function collectEmaSearchResults(
|
||||
genericStatus: entry.genericStatus,
|
||||
authorisationDate: entry.authorisationDate,
|
||||
source: "ema",
|
||||
packageOptions: [],
|
||||
score: bestMatch.score,
|
||||
});
|
||||
}
|
||||
@@ -623,6 +655,7 @@ function buildRxNormSearchResult(property: RxNormDrugConceptProperty): Medicatio
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: null,
|
||||
source: "rxnorm",
|
||||
packageOptions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -749,6 +782,165 @@ function normalizeOpenFdaName(value: unknown): string | null {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeOpenFdaPackagingText(value: string): string {
|
||||
return value
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseOpenFdaPackagingSegment(segment: string): ParsedOpenFdaPackagingSegment | null {
|
||||
const sanitized = sanitizeText(segment);
|
||||
if (!sanitized) return null;
|
||||
const match = /^(\d+(?:[.,]\d+)?)\s+(.+?)\s+in\s+(\d+(?:[.,]\d+)?)\s+(.+)$/i.exec(sanitized);
|
||||
if (!match) return null;
|
||||
|
||||
const quantity = Number(match[1].replace(",", "."));
|
||||
const containerCount = Number(match[3].replace(",", "."));
|
||||
if (!Number.isFinite(quantity) || quantity <= 0 || !Number.isFinite(containerCount) || containerCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
quantity,
|
||||
itemText: match[2].trim(),
|
||||
containerCount,
|
||||
containerText: match[4].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenFdaBlisterLikeContainer(containerText: string): boolean {
|
||||
const normalized = normalizeOpenFdaPackagingText(containerText);
|
||||
return (
|
||||
normalized.includes("BLISTER") ||
|
||||
normalized.includes("POUCH") ||
|
||||
normalized.includes("STRIP") ||
|
||||
normalized.includes("SACHET")
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenFdaBottleLikeContainer(containerText: string): boolean {
|
||||
const normalized = normalizeOpenFdaPackagingText(containerText);
|
||||
return normalized.includes("BOTTLE") || normalized.includes("JAR") || normalized.includes("VIAL");
|
||||
}
|
||||
|
||||
function isOpenFdaSolidUnit(itemText: string): boolean {
|
||||
const normalized = normalizeOpenFdaPackagingText(itemText);
|
||||
return (
|
||||
normalized.includes("TABLET") ||
|
||||
normalized.includes("CAPSULE") ||
|
||||
normalized.includes("CAPLET") ||
|
||||
normalized.includes("SOFTGEL") ||
|
||||
normalized.includes("LOZENGE")
|
||||
);
|
||||
}
|
||||
|
||||
function getOpenFdaAmountUnit(itemText: string): "ml" | "g" | null {
|
||||
const normalized = normalizeOpenFdaPackagingText(itemText);
|
||||
if (normalized.includes("ML")) return "ml";
|
||||
if (normalized === "G" || normalized.startsWith("G ") || normalized.includes("GRAM")) return "g";
|
||||
return null;
|
||||
}
|
||||
|
||||
function toPositiveInteger(value: number): number | null {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function uniquePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] {
|
||||
const byKey = new Map<string, MedicationEnrichmentPackageOption>();
|
||||
for (const option of options) {
|
||||
const key = JSON.stringify([
|
||||
option.description,
|
||||
option.packageType,
|
||||
option.packCount,
|
||||
option.blistersPerPack,
|
||||
option.pillsPerBlister,
|
||||
option.totalPills,
|
||||
option.packageAmountValue,
|
||||
option.packageAmountUnit,
|
||||
]);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, option);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
function buildOpenFdaPackageOptions(product: OpenFdaProduct): MedicationEnrichmentPackageOption[] {
|
||||
return uniquePackageOptions(
|
||||
(product.packaging ?? [])
|
||||
.map((entry): MedicationEnrichmentPackageOption | null => {
|
||||
const description = sanitizeText(entry.description);
|
||||
if (!description) return null;
|
||||
|
||||
const segments = description.split(/\s*\/\s*/).filter((value) => value.trim().length > 0);
|
||||
const outerSegment = segments.length > 1 ? parseOpenFdaPackagingSegment(segments[0] ?? "") : null;
|
||||
const primarySegment = parseOpenFdaPackagingSegment(segments[segments.length - 1] ?? "");
|
||||
if (!primarySegment) return null;
|
||||
|
||||
const packCount = toPositiveInteger(outerSegment?.quantity ?? 1) ?? 1;
|
||||
const packageAmountUnit = getOpenFdaAmountUnit(primarySegment.itemText);
|
||||
if (packageAmountUnit) {
|
||||
const packageAmountValue = toPositiveInteger(primarySegment.quantity);
|
||||
if (packageAmountValue === null) return null;
|
||||
const totalAmount = packCount * packageAmountValue;
|
||||
return {
|
||||
label: description,
|
||||
description,
|
||||
packageType: packageAmountUnit === "g" ? "tube" : "liquid_container",
|
||||
packCount,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: totalAmount,
|
||||
looseTablets: totalAmount,
|
||||
packageAmountValue,
|
||||
packageAmountUnit,
|
||||
} satisfies MedicationEnrichmentPackageOption;
|
||||
}
|
||||
|
||||
if (!isOpenFdaSolidUnit(primarySegment.itemText)) return null;
|
||||
const pillsPerUnit = toPositiveInteger(primarySegment.quantity);
|
||||
if (pillsPerUnit === null) return null;
|
||||
|
||||
if (isOpenFdaBlisterLikeContainer(primarySegment.containerText)) {
|
||||
return {
|
||||
label: description,
|
||||
description,
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: packCount,
|
||||
pillsPerBlister: pillsPerUnit,
|
||||
totalPills: packCount * pillsPerUnit,
|
||||
looseTablets: 0,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
} satisfies MedicationEnrichmentPackageOption;
|
||||
}
|
||||
|
||||
if (isOpenFdaBottleLikeContainer(primarySegment.containerText) || outerSegment === null) {
|
||||
const totalPills = packCount * pillsPerUnit;
|
||||
return {
|
||||
label: description,
|
||||
description,
|
||||
packageType: "bottle",
|
||||
packCount,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills,
|
||||
looseTablets: totalPills,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
} satisfies MedicationEnrichmentPackageOption;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((value): value is MedicationEnrichmentPackageOption => value !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichmentSearchResult | null {
|
||||
const code = sanitizeText(product.product_ndc) ?? sanitizeText(product.product_id);
|
||||
const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name);
|
||||
@@ -773,6 +965,7 @@ function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichment
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: parseCompactDate(product.marketing_start_date),
|
||||
source: "openfda",
|
||||
packageOptions: buildOpenFdaPackageOptions(product),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -852,6 +1045,7 @@ function buildOpenFdaEnrichment(product: OpenFdaProduct): OpenFdaEnrichment | nu
|
||||
genericName,
|
||||
strengthOptions: buildOpenFdaStrengthOptions(product),
|
||||
medicationForm: product.dosage_form ? deriveMedicationFormFromName(product.dosage_form) : null,
|
||||
packageOptions: buildOpenFdaPackageOptions(product),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1034,7 +1228,9 @@ export async function enrichMedicationSelection(
|
||||
);
|
||||
openFdaMatched =
|
||||
openFdaEnrichment !== null &&
|
||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
||||
(openFdaEnrichment.medicationForm !== null ||
|
||||
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||
openFdaEnrichment.packageOptions.length > 0);
|
||||
} catch (error) {
|
||||
partial = true;
|
||||
note = note ?? "Returned EMA enrichment without secondary-source suggestions.";
|
||||
@@ -1061,6 +1257,7 @@ export async function enrichMedicationSelection(
|
||||
rxNormEnrichment?.strengthOptions ?? [],
|
||||
openFdaEnrichment?.strengthOptions ?? []
|
||||
),
|
||||
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched,
|
||||
@@ -1099,7 +1296,9 @@ export async function enrichMedicationSelection(
|
||||
openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery(selection.genericName ?? selection.name);
|
||||
openFdaMatched =
|
||||
openFdaEnrichment !== null &&
|
||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
||||
(openFdaEnrichment.medicationForm !== null ||
|
||||
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||
openFdaEnrichment.packageOptions.length > 0);
|
||||
} catch (error) {
|
||||
partial = true;
|
||||
note = note ?? "Returned RxNorm enrichment without openFDA suggestions.";
|
||||
@@ -1126,6 +1325,7 @@ export async function enrichMedicationSelection(
|
||||
rxNormEnrichment?.strengthOptions ?? [],
|
||||
openFdaEnrichment?.strengthOptions ?? []
|
||||
),
|
||||
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched,
|
||||
@@ -1164,7 +1364,9 @@ export async function enrichMedicationSelection(
|
||||
openFdaEnrichment = buildOpenFdaEnrichment(product);
|
||||
openFdaMatched =
|
||||
openFdaEnrichment !== null &&
|
||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
||||
(openFdaEnrichment.medicationForm !== null ||
|
||||
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||
openFdaEnrichment.packageOptions.length > 0);
|
||||
|
||||
const openFdaGeneric = openFdaEnrichment?.genericName ?? request.genericName ?? request.name;
|
||||
try {
|
||||
@@ -1197,6 +1399,7 @@ export async function enrichMedicationSelection(
|
||||
rxNormEnrichment?.strengthOptions ?? [],
|
||||
openFdaEnrichment?.strengthOptions ?? []
|
||||
),
|
||||
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched,
|
||||
|
||||
@@ -176,6 +176,7 @@ describe("medication enrichment", () => {
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -203,9 +204,23 @@ describe("medication enrichment", () => {
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(response.results.find((result) => result.code === "00011-1111")?.packageOptions).toEqual([
|
||||
{
|
||||
label: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||
description: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: 20,
|
||||
looseTablets: 0,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
|
||||
it("prioritizes results with package sizes before source-only matches", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
@@ -242,6 +257,7 @@ describe("medication enrichment", () => {
|
||||
generic_name: "Acetylsalicylic acid",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -255,19 +271,72 @@ describe("medication enrichment", () => {
|
||||
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[1]).toMatchObject({
|
||||
code: "1191",
|
||||
source: "rxnorm",
|
||||
});
|
||||
expect(response.results[2]).toMatchObject({
|
||||
code: "EMA-ASPIRIN",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts richer package hits ahead of package-bearing results with fewer options", 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: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ibuprofen Max",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "60 tablets in 1 bottle" }, { description: "120 tablets in 1 bottle" }],
|
||||
},
|
||||
{
|
||||
product_ndc: "00022-2222",
|
||||
brand_name: "Ibuprofen Compact",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
packaging: [{ description: "20 tablets in 1 blister" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Ibuprofen", 3);
|
||||
|
||||
expect(response.results.slice(0, 2)).toMatchObject([
|
||||
{
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
},
|
||||
{
|
||||
code: "00022-2222",
|
||||
source: "openfda",
|
||||
},
|
||||
]);
|
||||
expect(response.results[0].packageOptions).toHaveLength(2);
|
||||
expect(response.results[1].packageOptions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("validates malformed search requests", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
@@ -346,6 +415,89 @@ describe("medication enrichment", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("includes package suggestions from openFDA fallback in route responses", async () => {
|
||||
const app = await buildApp();
|
||||
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.resolve(jsonResponse({ idGroup: {} }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Tylenol",
|
||||
generic_name: "Acetaminophen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Acetaminophen", strength: "500 mg" }],
|
||||
packaging: [{ description: "30 tablets in 1 bottle" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
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+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||
packageOptions: [
|
||||
{
|
||||
label: "30 tablets in 1 bottle",
|
||||
description: "30 tablets in 1 bottle",
|
||||
packageType: "bottle",
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 30,
|
||||
looseTablets: 30,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: true,
|
||||
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");
|
||||
|
||||
@@ -459,6 +611,7 @@ describe("medication enrichment", () => {
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||
packaging: [{ description: "100 mL in 1 bottle" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -506,6 +659,20 @@ describe("medication enrichment", () => {
|
||||
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||
],
|
||||
packageOptions: [
|
||||
{
|
||||
label: "100 mL in 1 bottle",
|
||||
description: "100 mL in 1 bottle",
|
||||
packageType: "liquid_container",
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 100,
|
||||
looseTablets: 100,
|
||||
packageAmountValue: 100,
|
||||
packageAmountUnit: "ml",
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
|
||||
+13
-4
@@ -6,7 +6,16 @@ Purpose: persistent agent work memory to survive context loss.
|
||||
|
||||
### 2026-03-25
|
||||
|
||||
- Task: Split the stock/refill semantics changes into a standalone release branch and repair split-induced frontend test corruption until focused local validation passed.
|
||||
- Decisions: Kept this branch limited to stock/refill semantics, repaired the shared MedicationsPage/UI tests against clean main structure, and kept root main clean by moving releasable scope into this worktree.
|
||||
- Files touched: backend/src/routes/medications.ts, backend/src/routes/refills.ts, backend/src/test/e2e-routes.test.ts, frontend/src/components/MedDetailModal.tsx, frontend/src/components/ReportModal.tsx, frontend/src/components/UserFilterModal.tsx, frontend/src/hooks/useRefill.ts, frontend/src/pages/MedicationsPage.tsx, frontend/src/test/components/MedDetailModal.test.tsx, frontend/src/test/components/ReportModal.test.tsx, frontend/src/test/components/UserFilterModal.test.tsx, frontend/src/test/hooks/useRefill.test.ts, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/test/types.test.ts, frontend/src/types/index.ts.
|
||||
- Follow-up: Create a dedicated bug issue, push the branch, open a PR, and wait for GitHub CI before merge.
|
||||
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
|
||||
- Root cause: The GitHub "Frontend Build" check actually failed in the frontend lint step because `frontend/src/test/pages/MedicationsPage.test.tsx` contained a whitespace-only line that Biome rejects.
|
||||
- Fix: Removed the stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` and revalidated frontend lint/build locally.
|
||||
|
||||
- Task: Split the medication enrichment lookup improvements into a standalone feature branch and repair the shared frontend tests until the focused validation set passed.
|
||||
- Decisions: Kept this branch limited to enrichment lookup/search/apply behavior, restored corrupted MedicationsPage and MobileEditModal test structure from clean main patterns, and retained desktop/mobile parity inside the feature scope.
|
||||
- Files touched: README.md, backend/src/routes/medication-enrichment.ts, backend/src/services/medication-enrichment.ts, backend/src/test/medication-enrichment.test.ts, frontend/src/components/MedicationEnrichmentSection.tsx, frontend/src/components/MobileEditModal.tsx, frontend/src/i18n/de.json, frontend/src/i18n/en.json, frontend/src/pages/MedicationsPage.tsx, frontend/src/styles.css, frontend/src/test/components/MedicationEnrichmentSection.test.tsx, frontend/src/test/components/MobileEditModal.test.tsx, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/types/index.ts, frontend/src/utils/index.ts, frontend/src/utils/medication-enrichment.ts.
|
||||
- Follow-up: Merge the refreshed feature branch once GitHub CI is green again.
|
||||
|
||||
- Task: Merge the refreshed feature branch on top of the already shipped stock/refill semantics changes without losing shared test coverage or work-log history.
|
||||
- Decisions: Kept the stock/refill doku history entries while resolving add/add conflicts and combined both branches' MedicationsPage tests in the shared file.
|
||||
- Files touched: doku/memory_notes.md, doku/report.md, frontend/src/test/pages/MedicationsPage.test.tsx.
|
||||
- Follow-up: Re-run the minimum frontend validation and push the conflict-resolution commit for PR #475.
|
||||
|
||||
+26
-7
@@ -3,14 +3,33 @@
|
||||
## Entries
|
||||
|
||||
### 2026-03-25
|
||||
- Scope: Isolate and validate the stock/refill semantics fix as its own PR-ready branch.
|
||||
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
|
||||
- What changed:
|
||||
- Consolidated the stock/refill behavior changes into a dedicated branch scope covering backend refill routes, stock display typing, and the affected medication detail/report/filter UI paths.
|
||||
- Repaired split-induced corruption in the shared MedicationsPage page and its focused test coverage so the branch is parse-clean and locally testable again.
|
||||
- Removed the obsolete backend refill-specific test file and kept the surviving backend coverage in the targeted e2e route suite.
|
||||
- Confirmed the GitHub "Frontend Build" job was failing in the frontend lint step, not in the Vite production build.
|
||||
- Removed a stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` that caused Biome formatting failure.
|
||||
- Validation:
|
||||
- `cd frontend && npm run lint`: passed after the whitespace fix.
|
||||
- `cd frontend && npm run build`: passed locally; production bundle build remains green.
|
||||
- Result: The branch was ready to push for CI re-run from a testing/build perspective.
|
||||
|
||||
### 2026-03-25
|
||||
- Scope: Isolate and validate the medication enrichment lookup work as its own PR-ready feature branch.
|
||||
- What changed:
|
||||
- Kept the branch focused on medication enrichment backend lookup logic, the shared lookup section, desktop/mobile editor parity, lookup utilities, translations, and the matching documentation update.
|
||||
- Repaired split-induced corruption in the shared MedicationsPage and MobileEditModal frontend tests so the feature branch is parse-clean and locally testable again.
|
||||
- Preserved the dedicated medication enrichment backend test file and added the shared frontend utility file used by the grouped lookup flow.
|
||||
- Validation:
|
||||
- Backend changed-file Biome: passed.
|
||||
- Frontend changed-file Biome: passed.
|
||||
- Backend Vitest `backend/src/test/e2e-routes.test.ts`: passed (`124` tests, `0` failures).
|
||||
- Frontend Vitest targeted stock/refill files: passed (`159` tests, `0` failures).
|
||||
- Result: This branch is locally green and ready for upstream PR creation.
|
||||
- Backend Vitest `backend/src/test/medication-enrichment.test.ts`: passed (`12` tests, `0` failures).
|
||||
- Frontend Vitest targeted medication enrichment files: passed (`116` tests, `0` failures).
|
||||
- Result: This branch was locally green and ready for upstream PR creation.
|
||||
|
||||
### 2026-03-25
|
||||
- Scope: Reconcile PR #475 with the already merged stock/refill branch so the feature PR can merge cleanly on top of the new main.
|
||||
- What changed:
|
||||
- Kept the required doku history from both PR tracks while resolving the add/add conflicts in `doku/memory_notes.md` and `doku/report.md`.
|
||||
- Combined the shared `frontend/src/test/pages/MedicationsPage.test.tsx` tail section so the medication enrichment tests and the already shipped stock-capacity list tests both remain present.
|
||||
- Validation:
|
||||
- Minimum frontend validation is rerun after conflict resolution before pushing the refreshed branch.
|
||||
- Result: The feature branch is conflict-free locally and ready for the final revalidation/push cycle.
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MedicationEnrichmentEnrichResponse,
|
||||
MedicationEnrichmentPackageOption,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
} from "../types";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import { getMedicationEnrichmentDisplayResultKey } from "../utils/medication-enrichment";
|
||||
|
||||
const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi;
|
||||
const PACKAGE_CONTENT_UNIT_PATTERNS = [
|
||||
{ pattern: /\bcapsules?\b/i, key: "capsule" },
|
||||
{ pattern: /\btablets?\b/i, key: "tablet" },
|
||||
{ pattern: /\bcaplets?\b/i, key: "caplet" },
|
||||
{ pattern: /\bpills?\b/i, key: "pill" },
|
||||
] as const;
|
||||
const INITIAL_VISIBLE_STRENGTH_OPTIONS = 12;
|
||||
|
||||
type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export interface MedicationEnrichmentViewModel {
|
||||
query: string;
|
||||
@@ -15,12 +28,15 @@ export interface MedicationEnrichmentViewModel {
|
||||
hasSearched: boolean;
|
||||
searchError: string | null;
|
||||
applyingCode: string | null;
|
||||
applyingPackageLabel: string | null;
|
||||
activeResultCode: string | null;
|
||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||
enrichError: string | null;
|
||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
appliedStrengthLabel: string | null;
|
||||
appliedPackageLabel: string | null;
|
||||
}
|
||||
|
||||
export interface MedicationEnrichmentSectionProps {
|
||||
@@ -28,8 +44,194 @@ export interface MedicationEnrichmentSectionProps {
|
||||
onQueryChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
onLoadMoreResults?: () => void;
|
||||
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onApplyResult: (
|
||||
result: MedicationEnrichmentSearchResult,
|
||||
preferredPackageOption?: MedicationEnrichmentPackageOption
|
||||
) => void;
|
||||
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
onApplyPackage: (option: MedicationEnrichmentPackageOption) => void;
|
||||
}
|
||||
|
||||
type MedicationEnrichmentPackageChoice = {
|
||||
option: MedicationEnrichmentPackageOption;
|
||||
sourceResult: MedicationEnrichmentSearchResult;
|
||||
};
|
||||
|
||||
type MedicationEnrichmentDisplayResult = {
|
||||
displayKey: string;
|
||||
representative: MedicationEnrichmentSearchResult;
|
||||
sourceResults: MedicationEnrichmentSearchResult[];
|
||||
packageChoices: MedicationEnrichmentPackageChoice[];
|
||||
firstIndex: number;
|
||||
};
|
||||
|
||||
function normalizePackageOptionDisplayText(value: string): string {
|
||||
return value
|
||||
.replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ")
|
||||
.replace(/\b([A-Z]{2,})\b/g, (match) => match.toLowerCase())
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getPackageContainerTranslationKey(packageType: MedicationEnrichmentPackageOption["packageType"]): string {
|
||||
switch (packageType) {
|
||||
case "blister":
|
||||
return "form.enrichment.packageContainers.blister";
|
||||
case "bottle":
|
||||
return "form.enrichment.packageContainers.bottle";
|
||||
case "liquid_container":
|
||||
return "form.enrichment.packageContainers.liquidContainer";
|
||||
case "tube":
|
||||
return "form.enrichment.packageContainers.tube";
|
||||
default:
|
||||
return "form.enrichment.packageContainers.bottle";
|
||||
}
|
||||
}
|
||||
|
||||
function detectPackageContentUnitKey(value: string): string {
|
||||
for (const candidate of PACKAGE_CONTENT_UNIT_PATTERNS) {
|
||||
if (candidate.pattern.test(value)) {
|
||||
return candidate.key;
|
||||
}
|
||||
}
|
||||
|
||||
return "tablet";
|
||||
}
|
||||
|
||||
function formatSolidPackageCount(count: number, sourceText: string, t: TranslateFunction): string {
|
||||
const unitKey = detectPackageContentUnitKey(sourceText);
|
||||
return `${count} ${t(`form.enrichment.packageUnits.${unitKey}`, { count })}`;
|
||||
}
|
||||
|
||||
function formatPackageContainerCount(option: MedicationEnrichmentPackageOption, t: TranslateFunction): string {
|
||||
return t(getPackageContainerTranslationKey(option.packageType), { count: Math.max(option.packCount, 1) });
|
||||
}
|
||||
|
||||
function buildPackageOptionKey(option: MedicationEnrichmentPackageOption): string {
|
||||
const sourceText = normalizePackageOptionDisplayText(option.description || option.label);
|
||||
const detectedUnit =
|
||||
option.packageType === "bottle" || option.packageType === "blister"
|
||||
? detectPackageContentUnitKey(sourceText)
|
||||
: null;
|
||||
|
||||
return JSON.stringify([
|
||||
option.packageType,
|
||||
option.packCount,
|
||||
option.blistersPerPack,
|
||||
option.pillsPerBlister,
|
||||
option.totalPills,
|
||||
option.looseTablets,
|
||||
option.packageAmountValue,
|
||||
option.packageAmountUnit,
|
||||
detectedUnit,
|
||||
]);
|
||||
}
|
||||
|
||||
function dedupePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] {
|
||||
const uniqueOptions = new Map<string, MedicationEnrichmentPackageOption>();
|
||||
|
||||
for (const option of options) {
|
||||
const key = buildPackageOptionKey(option);
|
||||
if (!uniqueOptions.has(key)) {
|
||||
uniqueOptions.set(key, option);
|
||||
}
|
||||
}
|
||||
|
||||
return [...uniqueOptions.values()];
|
||||
}
|
||||
|
||||
function formatPackageOptionDisplayText(
|
||||
value: MedicationEnrichmentPackageOption | string,
|
||||
t: TranslateFunction
|
||||
): string {
|
||||
const rawText = typeof value === "string" ? value : value.description || value.label;
|
||||
const cleanedText = normalizePackageOptionDisplayText(rawText);
|
||||
|
||||
if (typeof value === "string") {
|
||||
return cleanedText || rawText;
|
||||
}
|
||||
|
||||
const packageContainerLabel = formatPackageContainerCount(value, t);
|
||||
|
||||
if (value.packageType === "blister") {
|
||||
if (value.blistersPerPack !== null && value.blistersPerPack > 1 && value.pillsPerBlister !== null) {
|
||||
return `${packageContainerLabel} · ${value.blistersPerPack} × ${formatSolidPackageCount(
|
||||
value.pillsPerBlister,
|
||||
cleanedText,
|
||||
t
|
||||
)}`;
|
||||
}
|
||||
|
||||
const blisterCount = value.pillsPerBlister ?? value.totalPills;
|
||||
if (blisterCount !== null && blisterCount > 0) {
|
||||
return `${packageContainerLabel} · ${formatSolidPackageCount(blisterCount, cleanedText, t)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.packageType === "bottle") {
|
||||
const totalCount = value.totalPills ?? value.looseTablets;
|
||||
if (totalCount !== null && totalCount > 0) {
|
||||
const countPerContainer =
|
||||
value.packCount > 1 && totalCount % value.packCount === 0 ? totalCount / value.packCount : totalCount;
|
||||
return `${packageContainerLabel} · ${formatSolidPackageCount(countPerContainer, cleanedText, t)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(value.packageType === "liquid_container" || value.packageType === "tube") &&
|
||||
value.packageAmountValue !== null &&
|
||||
value.packageAmountUnit
|
||||
) {
|
||||
return `${packageContainerLabel} · ${value.packageAmountValue} ${value.packageAmountUnit}`;
|
||||
}
|
||||
|
||||
return cleanedText || rawText;
|
||||
}
|
||||
|
||||
function buildMedicationDisplayResults(
|
||||
results: MedicationEnrichmentSearchResult[]
|
||||
): MedicationEnrichmentDisplayResult[] {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
MedicationEnrichmentDisplayResult & { packageChoicesByKey: Map<string, MedicationEnrichmentPackageChoice> }
|
||||
>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const displayKey = getMedicationEnrichmentDisplayResultKey(result);
|
||||
const existing = grouped.get(displayKey);
|
||||
|
||||
if (!existing) {
|
||||
const packageChoicesByKey = new Map<string, MedicationEnrichmentPackageChoice>();
|
||||
for (const option of result.packageOptions) {
|
||||
packageChoicesByKey.set(buildPackageOptionKey(option), { option, sourceResult: result });
|
||||
}
|
||||
|
||||
grouped.set(displayKey, {
|
||||
displayKey,
|
||||
representative: result,
|
||||
sourceResults: [result],
|
||||
packageChoices: [...packageChoicesByKey.values()],
|
||||
packageChoicesByKey,
|
||||
firstIndex: index,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.sourceResults.push(result);
|
||||
for (const option of result.packageOptions) {
|
||||
const key = buildPackageOptionKey(option);
|
||||
if (!existing.packageChoicesByKey.has(key)) {
|
||||
existing.packageChoicesByKey.set(key, { option, sourceResult: result });
|
||||
}
|
||||
}
|
||||
existing.packageChoices = [...existing.packageChoicesByKey.values()];
|
||||
});
|
||||
|
||||
return [...grouped.values()]
|
||||
.sort(
|
||||
(left, right) => right.packageChoices.length - left.packageChoices.length || left.firstIndex - right.firstIndex
|
||||
)
|
||||
.map(({ packageChoicesByKey: _packageChoicesByKey, ...result }) => result);
|
||||
}
|
||||
|
||||
export function MedicationEnrichmentSection({
|
||||
@@ -39,6 +241,7 @@ export function MedicationEnrichmentSection({
|
||||
onLoadMoreResults,
|
||||
onApplyResult,
|
||||
onApplyStrength,
|
||||
onApplyPackage,
|
||||
}: MedicationEnrichmentSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
||||
@@ -49,13 +252,29 @@ export function MedicationEnrichmentSection({
|
||||
state.enrichError !== null ||
|
||||
state.results.length > 0 ||
|
||||
state.appliedSelection !== null ||
|
||||
state.packageOptions.length > 0 ||
|
||||
state.strengthOptions.length > 0 ||
|
||||
state.appliedPackageLabel !== null ||
|
||||
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 [visibleStrengthOptionCount, setVisibleStrengthOptionCount] = useState(INITIAL_VISIBLE_STRENGTH_OPTIONS);
|
||||
const autoExpandStateRef = useRef(shouldAutoExpand);
|
||||
const resultRefs = useRef(new Map<string, HTMLElement>());
|
||||
const displayResults = useMemo(() => buildMedicationDisplayResults(state.results), [state.results]);
|
||||
const uniqueStatePackageOptions = useMemo(() => dedupePackageOptions(state.packageOptions), [state.packageOptions]);
|
||||
const visibleStrengthOptions = state.strengthOptions.slice(0, visibleStrengthOptionCount);
|
||||
const hasMoreStrengthOptions = state.strengthOptions.length > visibleStrengthOptions.length;
|
||||
const appliedPackageOption = useMemo(
|
||||
() => state.packageOptions.find((option) => option.label === state.appliedPackageLabel) ?? null,
|
||||
[state.appliedPackageLabel, state.packageOptions]
|
||||
);
|
||||
const isLoadingInitialSearch = state.isSearching && displayResults.length === 0;
|
||||
const isLoadingMoreResults = state.isSearching && displayResults.length > 0;
|
||||
const showLoadMoreAction =
|
||||
displayResults.length > 0 && (state.hasMoreResults || isLoadingMoreResults) && onLoadMoreResults;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
||||
@@ -65,6 +284,26 @@ export function MedicationEnrichmentSection({
|
||||
autoExpandStateRef.current = shouldAutoExpand;
|
||||
}, [shouldAutoExpand]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleStrengthOptionCount(INITIAL_VISIBLE_STRENGTH_OPTIONS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedResultCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = window.requestAnimationFrame(() => {
|
||||
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(animationFrameId);
|
||||
}, [expandedResultCode]);
|
||||
|
||||
return (
|
||||
<div className="full medication-enrichment-section">
|
||||
<div className="medication-enrichment-header">
|
||||
@@ -74,7 +313,7 @@ export function MedicationEnrichmentSection({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
className={`medication-enrichment-toggle-button ${isExpanded ? "secondary small" : "primary small"}`}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
@@ -119,8 +358,16 @@ export function MedicationEnrichmentSection({
|
||||
}}
|
||||
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
|
||||
type="button"
|
||||
className={`secondary small medication-enrichment-action-button${isLoadingInitialSearch ? " is-loading" : ""}`}
|
||||
onClick={onSearch}
|
||||
disabled={!canSearch}
|
||||
>
|
||||
{isLoadingInitialSearch ? <span className="medication-enrichment-spinner" aria-hidden="true" /> : null}
|
||||
<span>
|
||||
{isLoadingInitialSearch ? t("form.enrichment.loadingSearch") : t("form.enrichment.searchAction")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
@@ -128,41 +375,79 @@ export function MedicationEnrichmentSection({
|
||||
{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 ? (
|
||||
{state.hasSearched && !state.isSearching && state.results.length === 0 && !state.searchError ? (
|
||||
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 ? (
|
||||
{displayResults.length > 0 ? (
|
||||
<div className="medication-enrichment-results">
|
||||
{state.results.map((result) => {
|
||||
const isActive = state.activeResultCode === result.code;
|
||||
{displayResults.map((displayResult) => {
|
||||
const { representative, sourceResults, packageChoices, displayKey } = displayResult;
|
||||
const isActive = sourceResults.some((result) => result.code === state.activeResultCode);
|
||||
const authorisationHolder =
|
||||
sourceResults.find((result) => result.authorisationHolder)?.authorisationHolder ?? null;
|
||||
const therapeuticArea = sourceResults.find((result) => result.therapeuticArea)?.therapeuticArea ?? null;
|
||||
const authorisationDate =
|
||||
sourceResults.find((result) => result.authorisationDate)?.authorisationDate ?? null;
|
||||
const hasPackageOptions = packageChoices.length > 0;
|
||||
const hasActiveStrengthOptions = isActive && state.strengthOptions.length > 0;
|
||||
const isApplyingPackageSelection =
|
||||
isActive && state.applyingCode !== null && state.applyingPackageLabel !== null;
|
||||
const hasDetails = Boolean(
|
||||
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
|
||||
authorisationHolder ||
|
||||
therapeuticArea ||
|
||||
authorisationDate ||
|
||||
hasPackageOptions ||
|
||||
hasActiveStrengthOptions ||
|
||||
isApplyingPackageSelection
|
||||
);
|
||||
const isDetailsExpanded = expandedResultCode === result.code;
|
||||
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
|
||||
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
|
||||
const isDetailsExpanded = expandedResultCode === displayKey;
|
||||
const activePackageOptions =
|
||||
isActive && uniqueStatePackageOptions.length > 0
|
||||
? uniqueStatePackageOptions
|
||||
: packageChoices.map((choice) => choice.option);
|
||||
const showInlinePackageChoices = activePackageOptions.length > 1;
|
||||
const genericStatusClass = representative.genericStatus === "generic" ? "success" : "neutral";
|
||||
const sourceClass = representative.source === "openfda" ? "warning" : "neutral";
|
||||
let applyLabel = t("form.enrichment.applyAction");
|
||||
if (state.applyingCode === result.code) {
|
||||
if (isActive && state.applyingCode !== null) {
|
||||
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" : ""}`}>
|
||||
<article
|
||||
key={displayKey}
|
||||
className={`medication-enrichment-result${isActive ? " active" : ""}`}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
resultRefs.current.set(displayKey, element);
|
||||
return;
|
||||
}
|
||||
|
||||
resultRefs.current.delete(displayKey);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<strong>{representative.name}</strong>
|
||||
{representative.genericName ? (
|
||||
<span className="medication-enrichment-result-generic">{representative.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 ${hasPackageOptions ? "success" : "neutral"}`}>
|
||||
{hasPackageOptions
|
||||
? t("form.enrichment.packageAvailable")
|
||||
: t("form.enrichment.packageUnavailable")}
|
||||
</span>
|
||||
<span className={`pill ${sourceClass}`}>
|
||||
{t(`form.enrichment.sources.${representative.source}`)}
|
||||
</span>
|
||||
{representative.source === "ema" ? (
|
||||
<span className={`pill ${genericStatusClass}`}>
|
||||
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
|
||||
{t(`form.enrichment.genericStatus.${representative.genericStatus}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
@@ -171,7 +456,7 @@ export function MedicationEnrichmentSection({
|
||||
className="ghost small"
|
||||
aria-expanded={isDetailsExpanded}
|
||||
onClick={() =>
|
||||
setExpandedResultCode((current) => (current === result.code ? null : result.code))
|
||||
setExpandedResultCode((current) => (current === displayKey ? null : displayKey))
|
||||
}
|
||||
>
|
||||
{isDetailsExpanded
|
||||
@@ -179,38 +464,162 @@ export function MedicationEnrichmentSection({
|
||||
: 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>
|
||||
{showInlinePackageChoices ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className={isActive ? "secondary small" : "primary small"}
|
||||
onClick={() => {
|
||||
setExpandedResultCode(displayKey);
|
||||
onApplyResult(representative);
|
||||
}}
|
||||
disabled={isActive && state.applyingCode !== null}
|
||||
>
|
||||
{applyLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDetails && isDetailsExpanded ? (
|
||||
<dl className="medication-enrichment-result-meta">
|
||||
{result.authorisationHolder ? (
|
||||
{authorisationHolder ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
||||
<dd>{result.authorisationHolder}</dd>
|
||||
<dd>{authorisationHolder}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.therapeuticArea ? (
|
||||
{therapeuticArea ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
||||
<dd>{result.therapeuticArea}</dd>
|
||||
<dd>{therapeuticArea}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.authorisationDate ? (
|
||||
{authorisationDate ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
||||
<dd>{formatDate(result.authorisationDate)}</dd>
|
||||
<dd>{formatDate(authorisationDate)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{activePackageOptions.length > 0 ? (
|
||||
<div className="medication-enrichment-result-meta-full">
|
||||
<dt>{t("form.enrichment.details.packageSizes")}</dt>
|
||||
<dd>
|
||||
<div className="medication-enrichment-detail-stack">
|
||||
{showInlinePackageChoices ? (
|
||||
<div className="medication-enrichment-strength-list medication-enrichment-package-choice-list">
|
||||
{activePackageOptions.map((option) => {
|
||||
const isApplyingPending =
|
||||
isApplyingPackageSelection && state.applyingPackageLabel === option.label;
|
||||
const isSelected =
|
||||
isActive &&
|
||||
(state.appliedPackageLabel === option.label ||
|
||||
(appliedPackageOption !== null &&
|
||||
buildPackageOptionKey(appliedPackageOption) ===
|
||||
buildPackageOptionKey(option)));
|
||||
const packageLabel = formatPackageOptionDisplayText(option, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
className={`medication-enrichment-package-choice-button ${isSelected || isApplyingPending ? "primary small" : "secondary small"}${isApplyingPending ? " is-loading" : ""}`}
|
||||
aria-pressed={isSelected}
|
||||
title={packageLabel}
|
||||
onClick={() =>
|
||||
isActive && uniqueStatePackageOptions.length > 0
|
||||
? onApplyPackage(option)
|
||||
: onApplyResult(
|
||||
packageChoices.find((choice) => choice.option.label === option.label)
|
||||
?.sourceResult ?? representative,
|
||||
option
|
||||
)
|
||||
}
|
||||
disabled={isActive && state.applyingCode !== null}
|
||||
>
|
||||
{isApplyingPending ? (
|
||||
<span className="medication-enrichment-spinner" aria-hidden="true" />
|
||||
) : null}
|
||||
<span>{packageLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="medication-enrichment-package-details">
|
||||
{activePackageOptions.map((option) => (
|
||||
<li key={option.label}>{formatPackageOptionDisplayText(option, t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isActive && state.appliedPackageLabel ? (
|
||||
<p className="success-text medication-enrichment-applied-note">
|
||||
{t("form.enrichment.appliedPackage", {
|
||||
label: formatPackageOptionDisplayText(
|
||||
appliedPackageOption ?? state.appliedPackageLabel,
|
||||
t
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{isApplyingPackageSelection ? (
|
||||
<div className="medication-enrichment-result-meta-full">
|
||||
<dt>{t("form.enrichment.strengthTitle")}</dt>
|
||||
<dd>
|
||||
<div className="medication-enrichment-pending-panel" aria-live="polite">
|
||||
<span className="medication-enrichment-spinner" aria-hidden="true" />
|
||||
<span>{t("form.enrichment.applying")}</span>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{hasActiveStrengthOptions ? (
|
||||
<div className="medication-enrichment-result-meta-full">
|
||||
<dt>{t("form.enrichment.strengthTitle")}</dt>
|
||||
<dd>
|
||||
<div className="medication-enrichment-detail-stack">
|
||||
<p className="sub medication-enrichment-detail-hint">
|
||||
{t("form.enrichment.strengthHint")}
|
||||
</p>
|
||||
<div className="medication-enrichment-strength-list">
|
||||
{visibleStrengthOptions.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>
|
||||
{hasMoreStrengthOptions ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small medication-enrichment-inline-action"
|
||||
onClick={() =>
|
||||
setVisibleStrengthOptionCount(
|
||||
(current) => current + INITIAL_VISIBLE_STRENGTH_OPTIONS
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("form.enrichment.showMoreStrengthsAction")}
|
||||
</button>
|
||||
) : null}
|
||||
{state.appliedStrengthLabel ? (
|
||||
<p className="success-text medication-enrichment-applied-note">
|
||||
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
@@ -221,61 +630,21 @@ export function MedicationEnrichmentSection({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
|
||||
{showLoadMoreAction ? (
|
||||
<div className="medication-enrichment-results-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
className={`secondary small medication-enrichment-action-button medication-enrichment-load-more-button${isLoadingMoreResults ? " is-loading" : ""}`}
|
||||
onClick={onLoadMoreResults}
|
||||
disabled={state.isSearching || Boolean(state.applyingCode)}
|
||||
>
|
||||
{t("form.enrichment.showMoreAction")}
|
||||
{isLoadingMoreResults ? <span className="medication-enrichment-spinner" aria-hidden="true" /> : null}
|
||||
<span>
|
||||
{isLoadingMoreResults ? t("form.enrichment.loadingMoreResults") : t("form.enrichment.showMoreAction")}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
FormIntake,
|
||||
FormState,
|
||||
Medication,
|
||||
MedicationEnrichmentPackageOption,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
} from "../types";
|
||||
@@ -59,12 +60,15 @@ const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
};
|
||||
|
||||
export interface MobileEditModalProps {
|
||||
@@ -76,8 +80,12 @@ export interface MobileEditModalProps {
|
||||
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
||||
onMedicationEnrichmentSearch?: () => void;
|
||||
onMedicationEnrichmentLoadMore?: () => void;
|
||||
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onMedicationEnrichmentApply?: (
|
||||
result: MedicationEnrichmentSearchResult,
|
||||
preferredPackageOption?: MedicationEnrichmentPackageOption
|
||||
) => void;
|
||||
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
onMedicationEnrichmentPackageApply?: (option: MedicationEnrichmentPackageOption) => void;
|
||||
fieldErrors: FieldErrors;
|
||||
saving: boolean;
|
||||
formSaved: boolean;
|
||||
@@ -136,6 +144,7 @@ export function MobileEditModal({
|
||||
onMedicationEnrichmentLoadMore = () => {},
|
||||
onMedicationEnrichmentApply = () => {},
|
||||
onMedicationEnrichmentStrengthApply = () => {},
|
||||
onMedicationEnrichmentPackageApply = () => {},
|
||||
fieldErrors,
|
||||
saving,
|
||||
formSaved,
|
||||
@@ -492,6 +501,7 @@ export function MobileEditModal({
|
||||
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
||||
onApplyResult={onMedicationEnrichmentApply}
|
||||
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
||||
onApplyPackage={onMedicationEnrichmentPackageApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
|
||||
@@ -228,35 +228,65 @@
|
||||
"enrichment": {
|
||||
"title": "Optionale Medikamentensuche",
|
||||
"coverageLabel": "Unvollständige freie Abdeckung",
|
||||
"collapsedHint": "Öffne das nur, wenn du Suchvorschläge nutzen möchtest.",
|
||||
"collapsedHint": "Nur verwenden, wenn du Suchvorschläge brauchst.",
|
||||
"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.",
|
||||
"infoTitle": "Hinweise zu den Quellen",
|
||||
"description": "Die Ergebnisse stammen in erster Linie aus RxNorm und openFDA. EMA wird nur als Fallback verwendet, wenn es nötig ist. Prüfe jeden Vorschlag, bevor du ihn ins Formular übernimmst.",
|
||||
"searchLabel": "Medikamentenquellen durchsuchen",
|
||||
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
|
||||
"searchAction": "Suchen",
|
||||
"searching": "Suche läuft...",
|
||||
"loadingSearch": "Medikamentenquellen werden durchsucht...",
|
||||
"loadingMoreResults": "Weitere Treffer werden geladen...",
|
||||
"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.",
|
||||
"manualEntryHint": "Die Suchvorschläge sind optional und decken möglicherweise nicht jedes Medikament, jede Stärke, jede Packungsgröße oder jede lokale Marktvariante ab.",
|
||||
"authRequired": "Für die Medikamentensuche ist eine aktive Anmeldung erforderlich. Bitte melde dich erneut an oder fahre mit der manuellen Eingabe fort.",
|
||||
"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.",
|
||||
"packageAvailable": "Packungsgröße",
|
||||
"packageUnavailable": "Keine Packungsgröße",
|
||||
"packageTitle": "Packungsgrößen-Vorschläge",
|
||||
"packageHint": "Wähle eine Packungsgröße aus, um die Bestandsfelder zu aktualisieren.",
|
||||
"appliedPackage": "Übernommene Packungsgröße: {{label}}",
|
||||
"packageContainers": {
|
||||
"blister_one": "1 Blisterpackung",
|
||||
"blister_other": "{{count}} Blisterpackungen",
|
||||
"bottle_one": "1 Flasche",
|
||||
"bottle_other": "{{count}} Flaschen",
|
||||
"liquidContainer_one": "1 Flasche",
|
||||
"liquidContainer_other": "{{count}} Flaschen",
|
||||
"tube_one": "1 Tube",
|
||||
"tube_other": "{{count}} Tuben"
|
||||
},
|
||||
"packageUnits": {
|
||||
"tablet_one": "Tablette",
|
||||
"tablet_other": "Tabletten",
|
||||
"capsule_one": "Kapsel",
|
||||
"capsule_other": "Kapseln",
|
||||
"caplet_one": "Caplet",
|
||||
"caplet_other": "Caplets",
|
||||
"pill_one": "Pille",
|
||||
"pill_other": "Pillen"
|
||||
},
|
||||
"strengthTitle": "Stärke-Vorschläge",
|
||||
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
|
||||
"showMoreStrengthsAction": "Mehr anzeigen",
|
||||
"appliedStrength": "Übernommene Stärke: {{label}}",
|
||||
"details": {
|
||||
"showAction": "Mehr Details",
|
||||
"hideAction": "Weniger Details",
|
||||
"authorisationHolder": "Zulassungsinhaber",
|
||||
"therapeuticArea": "Therapiebereich",
|
||||
"authorisationDate": "Zulassungsdatum"
|
||||
"authorisationDate": "Zulassungsdatum",
|
||||
"packageSizes": "Packungsgrößen"
|
||||
},
|
||||
"genericStatus": {
|
||||
"generic": "Generikum",
|
||||
|
||||
@@ -228,35 +228,65 @@
|
||||
"enrichment": {
|
||||
"title": "Optional medication lookup",
|
||||
"coverageLabel": "Incomplete free-source coverage",
|
||||
"collapsedHint": "Open this only if you want lookup suggestions.",
|
||||
"collapsedHint": "Use this only when 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.",
|
||||
"infoTitle": "Source notes",
|
||||
"description": "Results are primarily sourced from RxNorm and openFDA. EMA is used only as a fallback when needed. Review each suggestion before applying it to the form.",
|
||||
"searchLabel": "Search medication sources",
|
||||
"searchPlaceholder": "Search by brand or ingredient",
|
||||
"searchAction": "Search",
|
||||
"searching": "Searching...",
|
||||
"loadingSearch": "Searching medication sources...",
|
||||
"loadingMoreResults": "Loading more results...",
|
||||
"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.",
|
||||
"manualEntryHint": "Lookup suggestions are optional and may not cover every medication, strength, package size, or local market variant.",
|
||||
"authRequired": "Medication lookup requires an active sign-in. Please sign in again or continue with manual entry.",
|
||||
"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.",
|
||||
"packageAvailable": "Package size",
|
||||
"packageUnavailable": "No package size",
|
||||
"packageTitle": "Package size suggestions",
|
||||
"packageHint": "Choose a package size to update the stock fields.",
|
||||
"appliedPackage": "Applied package size: {{label}}",
|
||||
"packageContainers": {
|
||||
"blister_one": "1 blister pack",
|
||||
"blister_other": "{{count}} blister packs",
|
||||
"bottle_one": "1 bottle",
|
||||
"bottle_other": "{{count}} bottles",
|
||||
"liquidContainer_one": "1 bottle",
|
||||
"liquidContainer_other": "{{count}} bottles",
|
||||
"tube_one": "1 tube",
|
||||
"tube_other": "{{count}} tubes"
|
||||
},
|
||||
"packageUnits": {
|
||||
"tablet_one": "tablet",
|
||||
"tablet_other": "tablets",
|
||||
"capsule_one": "capsule",
|
||||
"capsule_other": "capsules",
|
||||
"caplet_one": "caplet",
|
||||
"caplet_other": "caplets",
|
||||
"pill_one": "pill",
|
||||
"pill_other": "pills"
|
||||
},
|
||||
"strengthTitle": "Strength suggestions",
|
||||
"strengthHint": "Choose a strength to update dose per pill and unit.",
|
||||
"showMoreStrengthsAction": "Show more",
|
||||
"appliedStrength": "Applied strength: {{label}}",
|
||||
"details": {
|
||||
"showAction": "More details",
|
||||
"hideAction": "Less details",
|
||||
"authorisationHolder": "Authorisation holder",
|
||||
"therapeuticArea": "Therapeutic area",
|
||||
"authorisationDate": "Authorisation date"
|
||||
"authorisationDate": "Authorisation date",
|
||||
"packageSizes": "Package sizes"
|
||||
},
|
||||
"genericStatus": {
|
||||
"generic": "Generic",
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
FormState,
|
||||
Medication,
|
||||
MedicationEnrichmentEnrichResponse,
|
||||
MedicationEnrichmentPackageOption,
|
||||
MedicationEnrichmentSearchResponse,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
WEEKDAY_CODES,
|
||||
} from "../utils/intake-schedule";
|
||||
import { log } from "../utils/logger";
|
||||
import { countMedicationEnrichmentDisplayResults } from "../utils/medication-enrichment";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
@@ -64,6 +66,7 @@ const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
||||
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
|
||||
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
|
||||
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
|
||||
const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi;
|
||||
|
||||
type MedicationEnrichmentState = {
|
||||
query: string;
|
||||
@@ -74,12 +77,15 @@ type MedicationEnrichmentState = {
|
||||
hasSearched: boolean;
|
||||
searchError: string | null;
|
||||
applyingCode: string | null;
|
||||
applyingPackageLabel: string | null;
|
||||
activeResultCode: string | null;
|
||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||
enrichError: string | null;
|
||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
appliedStrengthLabel: string | null;
|
||||
appliedPackageLabel: string | null;
|
||||
};
|
||||
|
||||
function createMedicationEnrichmentState(
|
||||
@@ -95,12 +101,15 @@ function createMedicationEnrichmentState(
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +119,40 @@ function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengt
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMedicationEnrichmentPackageText(value: string): string {
|
||||
return value.replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function hasMatchingMedicationEnrichmentPackageStructure(
|
||||
left: MedicationEnrichmentPackageOption,
|
||||
right: MedicationEnrichmentPackageOption
|
||||
): boolean {
|
||||
return (
|
||||
left.packageType === right.packageType &&
|
||||
left.packCount === right.packCount &&
|
||||
left.blistersPerPack === right.blistersPerPack &&
|
||||
left.pillsPerBlister === right.pillsPerBlister &&
|
||||
left.totalPills === right.totalPills &&
|
||||
left.looseTablets === right.looseTablets &&
|
||||
left.packageAmountValue === right.packageAmountValue &&
|
||||
left.packageAmountUnit === right.packageAmountUnit
|
||||
);
|
||||
}
|
||||
|
||||
function matchesMedicationEnrichmentPackageOption(
|
||||
left: MedicationEnrichmentPackageOption,
|
||||
right: MedicationEnrichmentPackageOption
|
||||
): boolean {
|
||||
const leftTexts = [left.label, left.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean);
|
||||
const rightTexts = [right.label, right.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean);
|
||||
const hasMatchingText = leftTexts.some((text) => rightTexts.includes(text));
|
||||
|
||||
return (
|
||||
hasMatchingMedicationEnrichmentPackageStructure(left, right) ||
|
||||
(hasMatchingText && left.packageType === right.packageType)
|
||||
);
|
||||
}
|
||||
|
||||
function applyMedicationEnrichmentSuggestions(
|
||||
form: FormState,
|
||||
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
|
||||
@@ -153,7 +196,53 @@ function applyMedicationEnrichmentStrength(
|
||||
};
|
||||
}
|
||||
|
||||
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
function applyMedicationEnrichmentPackage(form: FormState, option: MedicationEnrichmentPackageOption): FormState {
|
||||
const nextForm: FormState = {
|
||||
...form,
|
||||
packageType: option.packageType,
|
||||
packCount: `${option.packCount}`,
|
||||
blistersPerPack: option.blistersPerPack !== null ? `${option.blistersPerPack}` : "1",
|
||||
pillsPerBlister: option.pillsPerBlister !== null ? `${option.pillsPerBlister}` : "1",
|
||||
packageAmountValue: option.packageAmountValue !== null ? `${option.packageAmountValue}` : "",
|
||||
packageAmountUnit: option.packageAmountUnit ?? form.packageAmountUnit,
|
||||
totalPills: option.totalPills !== null ? `${option.totalPills}` : "",
|
||||
looseTablets: option.looseTablets !== null ? `${option.looseTablets}` : "0",
|
||||
};
|
||||
|
||||
if (option.packageType === "blister") {
|
||||
return {
|
||||
...nextForm,
|
||||
totalPills: "",
|
||||
looseTablets: "0",
|
||||
};
|
||||
}
|
||||
|
||||
if (option.packageType === "liquid_container") {
|
||||
return {
|
||||
...nextForm,
|
||||
medicationForm: "liquid",
|
||||
};
|
||||
}
|
||||
|
||||
if (option.packageType === "tube") {
|
||||
return {
|
||||
...nextForm,
|
||||
medicationForm: "topical",
|
||||
};
|
||||
}
|
||||
|
||||
return nextForm;
|
||||
}
|
||||
|
||||
async function getMedicationEnrichmentErrorMessage(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
unauthorizedFallback: string
|
||||
): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
return unauthorizedFallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const errorBody = (await response.json()) as { error?: string; message?: string };
|
||||
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
|
||||
@@ -284,12 +373,15 @@ export function MedicationsPage() {
|
||||
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
|
||||
createMedicationEnrichmentState()
|
||||
);
|
||||
const medicationEnrichmentQueryRef = useRef("");
|
||||
|
||||
const resetMedicationEnrichment = useCallback((query = "") => {
|
||||
medicationEnrichmentQueryRef.current = query;
|
||||
setMedicationEnrichment(createMedicationEnrichmentState(query));
|
||||
}, []);
|
||||
|
||||
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
|
||||
medicationEnrichmentQueryRef.current = value;
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
query: value,
|
||||
@@ -297,21 +389,28 @@ export function MedicationsPage() {
|
||||
}, []);
|
||||
|
||||
const performMedicationEnrichmentSearch = useCallback(
|
||||
async (requestedLimit: number, preserveExistingResults = false) => {
|
||||
const trimmedQuery = medicationEnrichment.query.trim();
|
||||
async (
|
||||
requestedLimit: number,
|
||||
preserveExistingResults = false,
|
||||
previousVisibleResultCount = countMedicationEnrichmentDisplayResults(medicationEnrichment.results),
|
||||
queryOverride?: string
|
||||
) => {
|
||||
const trimmedQuery = (queryOverride ?? medicationEnrichmentQueryRef.current).trim();
|
||||
if (!trimmedQuery) return;
|
||||
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
|
||||
medicationEnrichmentQueryRef.current = trimmedQuery;
|
||||
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
query: trimmedQuery,
|
||||
results: preserveExistingResults ? previous.results : [],
|
||||
hasMoreResults: false,
|
||||
hasMoreResults: preserveExistingResults ? previous.hasMoreResults : false,
|
||||
resultLimit: limit,
|
||||
isSearching: true,
|
||||
hasSearched: preserveExistingResults ? previous.hasSearched : false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
...(preserveExistingResults
|
||||
? {}
|
||||
: {
|
||||
@@ -320,7 +419,9 @@ export function MedicationsPage() {
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -331,21 +432,50 @@ export function MedicationsPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
|
||||
throw new Error(
|
||||
await getMedicationEnrichmentErrorMessage(
|
||||
response,
|
||||
t("form.enrichment.searchError"),
|
||||
t("form.enrichment.authRequired")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
|
||||
const nextResults = Array.isArray(data.results) ? data.results : [];
|
||||
const nextVisibleResultCount = countMedicationEnrichmentDisplayResults(nextResults);
|
||||
const reachedResultLimitCap = limit >= MEDICATION_ENRICHMENT_MAX_LIMIT;
|
||||
const shouldLoadUntilVisibleResultChanges =
|
||||
preserveExistingResults &&
|
||||
Boolean(data.hasMore) &&
|
||||
!reachedResultLimitCap &&
|
||||
nextVisibleResultCount <= previousVisibleResultCount;
|
||||
|
||||
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,
|
||||
}));
|
||||
if (shouldLoadUntilVisibleResultChanges) {
|
||||
await performMedicationEnrichmentSearch(
|
||||
Math.min(limit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||
true,
|
||||
previousVisibleResultCount,
|
||||
trimmedQuery
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMedicationEnrichment((previous) => {
|
||||
const loadedAdditionalResults = !preserveExistingResults || nextResults.length > previous.results.length;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
query: data.query,
|
||||
results: nextResults,
|
||||
hasMoreResults: Boolean(data.hasMore) && !reachedResultLimitCap && loadedAdditionalResults,
|
||||
resultLimit: limit,
|
||||
isSearching: false,
|
||||
hasSearched: true,
|
||||
searchError: null,
|
||||
};
|
||||
});
|
||||
medicationEnrichmentQueryRef.current = data.query;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
|
||||
@@ -361,7 +491,7 @@ export function MedicationsPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[medicationEnrichment.query, t]
|
||||
[medicationEnrichment.query, medicationEnrichment.results, t]
|
||||
);
|
||||
|
||||
const handlePendingMedicationImageSelection = useCallback(
|
||||
@@ -409,6 +539,15 @@ export function MedicationsPage() {
|
||||
});
|
||||
}, [user?.id]);
|
||||
|
||||
const handleMedicationEnrichmentSearch = useCallback(async () => {
|
||||
await performMedicationEnrichmentSearch(
|
||||
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||
false,
|
||||
countMedicationEnrichmentDisplayResults(medicationEnrichment.results),
|
||||
medicationEnrichmentQueryRef.current
|
||||
);
|
||||
}, [medicationEnrichment.results, performMedicationEnrichmentSearch]);
|
||||
|
||||
const loadAllMeds = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
@@ -462,17 +601,27 @@ export function MedicationsPage() {
|
||||
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
|
||||
if (!allowsPillFormSelection(form.packageType)) return [];
|
||||
|
||||
return medicationEnrichment.strengthOptions.filter(
|
||||
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
||||
);
|
||||
return [...medicationEnrichment.strengthOptions]
|
||||
.filter(
|
||||
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const leftWeight = left.pillWeightMg ?? Number.POSITIVE_INFINITY;
|
||||
const rightWeight = right.pillWeightMg ?? Number.POSITIVE_INFINITY;
|
||||
if (leftWeight !== rightWeight) {
|
||||
return leftWeight - rightWeight;
|
||||
}
|
||||
return left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||
});
|
||||
}, [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;
|
||||
if (
|
||||
medicationEnrichment.isSearching ||
|
||||
!medicationEnrichment.hasMoreResults ||
|
||||
medicationEnrichment.resultLimit >= MEDICATION_ENRICHMENT_MAX_LIMIT
|
||||
)
|
||||
return;
|
||||
await performMedicationEnrichmentSearch(
|
||||
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||
true
|
||||
@@ -485,16 +634,19 @@ export function MedicationsPage() {
|
||||
]);
|
||||
|
||||
const handleMedicationEnrichmentApply = useCallback(
|
||||
async (result: MedicationEnrichmentSearchResult) => {
|
||||
async (result: MedicationEnrichmentSearchResult, preferredPackageOption?: MedicationEnrichmentPackageOption) => {
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: result.code,
|
||||
applyingPackageLabel: preferredPackageOption?.label ?? null,
|
||||
activeResultCode: result.code,
|
||||
enrichError: null,
|
||||
appliedSelection: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -512,12 +664,32 @@ export function MedicationsPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
|
||||
throw new Error(
|
||||
await getMedicationEnrichmentErrorMessage(
|
||||
response,
|
||||
t("form.enrichment.applyError"),
|
||||
t("form.enrichment.authRequired")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
|
||||
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
|
||||
let appliedPackageLabel: string | null = null;
|
||||
let appliedStrengthLabel: string | null = null;
|
||||
const matchedPreferredPackageOption = preferredPackageOption
|
||||
? (data.suggestions.packageOptions.find((option) =>
|
||||
matchesMedicationEnrichmentPackageOption(option, preferredPackageOption)
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
if (matchedPreferredPackageOption) {
|
||||
nextForm = applyMedicationEnrichmentPackage(nextForm, matchedPreferredPackageOption);
|
||||
appliedPackageLabel = matchedPreferredPackageOption.label;
|
||||
} else if (data.suggestions.packageOptions.length === 1) {
|
||||
nextForm = applyMedicationEnrichmentPackage(nextForm, data.suggestions.packageOptions[0]);
|
||||
appliedPackageLabel = data.suggestions.packageOptions[0].label;
|
||||
}
|
||||
|
||||
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
|
||||
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
|
||||
@@ -531,12 +703,15 @@ export function MedicationsPage() {
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
activeResultCode: result.code,
|
||||
appliedSelection: data.selection,
|
||||
enrichError: null,
|
||||
meta: data.meta,
|
||||
strengthOptions: data.suggestions.strengthOptions,
|
||||
packageOptions: data.suggestions.packageOptions,
|
||||
appliedStrengthLabel,
|
||||
appliedPackageLabel,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message =
|
||||
@@ -545,12 +720,15 @@ export function MedicationsPage() {
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: message,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
@@ -571,6 +749,19 @@ export function MedicationsPage() {
|
||||
[setForm]
|
||||
);
|
||||
|
||||
const handleMedicationEnrichmentPackageApply = useCallback(
|
||||
(option: MedicationEnrichmentPackageOption) => {
|
||||
setForm((currentForm) => applyMedicationEnrichmentPackage(currentForm, option));
|
||||
setMedicationEnrichment((previous) => ({
|
||||
...previous,
|
||||
appliedPackageLabel: option.label,
|
||||
applyingPackageLabel: null,
|
||||
appliedStrengthLabel: null,
|
||||
}));
|
||||
},
|
||||
[setForm]
|
||||
);
|
||||
|
||||
const medicationEnrichmentViewModel = useMemo(
|
||||
() => ({
|
||||
...medicationEnrichment,
|
||||
@@ -1635,6 +1826,7 @@ export function MedicationsPage() {
|
||||
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
||||
onApplyResult={handleMedicationEnrichmentApply}
|
||||
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
||||
onApplyPackage={handleMedicationEnrichmentPackageApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
@@ -2300,6 +2492,7 @@ export function MedicationsPage() {
|
||||
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
||||
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
||||
onMedicationEnrichmentPackageApply={handleMedicationEnrichmentPackageApply}
|
||||
fieldErrors={fieldErrors}
|
||||
saving={saving}
|
||||
formSaved={formSaved}
|
||||
|
||||
+163
-18
@@ -2097,8 +2097,7 @@ button.has-validation-error {
|
||||
|
||||
.medication-enrichment-collapsed-hint,
|
||||
.medication-enrichment-description,
|
||||
.medication-enrichment-manual-hint,
|
||||
.medication-enrichment-selection-summary {
|
||||
.medication-enrichment-manual-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -2133,21 +2132,95 @@ button.has-validation-error {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-spinner {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
border-top-color: var(--accent-light);
|
||||
animation: medication-enrichment-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-search-row button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.medication-enrichment-action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.medication-enrichment-action-button.is-loading {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, var(--border-primary));
|
||||
background: color-mix(in srgb, var(--accent-bg) 44%, var(--bg-secondary));
|
||||
box-shadow:
|
||||
0 10px 24px rgba(47, 134, 246, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.medication-enrichment-action-button.is-loading:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.medication-enrichment-results {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-toggle-button {
|
||||
min-width: 11rem;
|
||||
justify-self: flex-end;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.medication-enrichment-toggle-button.primary {
|
||||
box-shadow:
|
||||
0 10px 24px rgba(47, 134, 246, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.medication-enrichment-toggle-button.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 14px 28px rgba(47, 134, 246, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes medication-enrichment-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.medication-enrichment-results-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.medication-enrichment-load-more-button.is-loading {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--border-primary));
|
||||
background: color-mix(in srgb, var(--accent-bg) 38%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
.medication-enrichment-result {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
@@ -2164,8 +2237,8 @@ button.has-validation-error {
|
||||
|
||||
.medication-enrichment-result-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -2187,30 +2260,42 @@ button.has-validation-error {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-package {
|
||||
font-size: 0.8rem;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
gap: 0.9rem 1.15rem;
|
||||
margin: 0;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.medication-enrichment-result-meta dt {
|
||||
margin-bottom: 0.15rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -2222,14 +2307,16 @@ button.has-validation-error {
|
||||
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-detail-stack {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-package-details {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-strengths {
|
||||
@@ -2248,13 +2335,71 @@ button.has-validation-error {
|
||||
.medication-enrichment-strength-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-strength-list button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.medication-enrichment-detail-hint {
|
||||
margin: 0;
|
||||
max-width: 44rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.medication-enrichment-applied-note {
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--success) 35%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--success-bg) 75%, transparent);
|
||||
line-height: 1.45;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.medication-enrichment-inline-action {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.medication-enrichment-package-choice-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.medication-enrichment-package-choice-list button.medication-enrichment-package-choice-button {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.65rem;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.medication-enrichment-package-choice-list button.medication-enrichment-package-choice-button.is-loading {
|
||||
box-shadow:
|
||||
0 10px 24px rgba(47, 134, 246, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.medication-enrichment-pending-panel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border-primary));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--accent-bg) 54%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.medication-enrichment-header,
|
||||
.medication-enrichment-result-header,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,16 +99,20 @@ function createMedicationEnrichmentState(
|
||||
return {
|
||||
query: "",
|
||||
results: [],
|
||||
hasMoreResults: false,
|
||||
isSearching: false,
|
||||
hasSearched: false,
|
||||
searchError: null,
|
||||
applyingCode: null,
|
||||
applyingPackageLabel: null,
|
||||
activeResultCode: null,
|
||||
appliedSelection: null,
|
||||
enrichError: null,
|
||||
meta: null,
|
||||
strengthOptions: [],
|
||||
packageOptions: [],
|
||||
appliedStrengthLabel: null,
|
||||
appliedPackageLabel: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -198,6 +202,7 @@ describe("MobileEditModal", () => {
|
||||
const onMedicationEnrichmentSearch = vi.fn();
|
||||
const onMedicationEnrichmentApply = vi.fn();
|
||||
const onMedicationEnrichmentStrengthApply = vi.fn();
|
||||
const onMedicationEnrichmentPackageApply = vi.fn();
|
||||
const result = {
|
||||
code: "RX-123",
|
||||
name: "Wegovy",
|
||||
@@ -208,8 +213,21 @@ describe("MobileEditModal", () => {
|
||||
genericStatus: "unknown" as const,
|
||||
authorisationDate: null,
|
||||
source: "rxnorm" as const,
|
||||
packageOptions: [],
|
||||
};
|
||||
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
|
||||
const packageOption = {
|
||||
label: "60 tablets in 1 bottle",
|
||||
description: "60 tablets in 1 bottle",
|
||||
packageType: "bottle" as const,
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 60,
|
||||
looseTablets: 60,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MobileEditModal
|
||||
@@ -217,12 +235,22 @@ describe("MobileEditModal", () => {
|
||||
medicationEnrichment={createMedicationEnrichmentState({
|
||||
query: "Wegovy",
|
||||
results: [result],
|
||||
appliedSelection: {
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
therapeuticArea: null,
|
||||
indication: null,
|
||||
atcCode: null,
|
||||
source: "rxnorm",
|
||||
},
|
||||
strengthOptions: [strengthOption],
|
||||
packageOptions: [packageOption],
|
||||
})}
|
||||
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
|
||||
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
|
||||
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
|
||||
onMedicationEnrichmentPackageApply={onMedicationEnrichmentPackageApply}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -232,12 +260,76 @@ describe("MobileEditModal", () => {
|
||||
});
|
||||
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);
|
||||
expect(onMedicationEnrichmentStrengthApply).not.toHaveBeenCalled();
|
||||
expect(onMedicationEnrichmentPackageApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards inline package option clicks with the preferred package payload in the mobile editor", () => {
|
||||
const onMedicationEnrichmentApply = vi.fn();
|
||||
const packageOptions = [
|
||||
{
|
||||
label: "10 tablets in 1 blister (59651-083-14)",
|
||||
description: "10 tablets in 1 blister (59651-083-14)",
|
||||
packageType: "blister" as const,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: 10,
|
||||
looseTablets: 0,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
{
|
||||
label: "30 tablets in 1 bottle (00093-7424-56)",
|
||||
description: "30 tablets in 1 bottle (00093-7424-56)",
|
||||
packageType: "bottle" as const,
|
||||
packCount: 1,
|
||||
blistersPerPack: null,
|
||||
pillsPerBlister: null,
|
||||
totalPills: 30,
|
||||
looseTablets: 30,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
},
|
||||
];
|
||||
const result = {
|
||||
code: "NDC-123",
|
||||
name: "Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "brand" as const,
|
||||
genericStatus: "unknown" as const,
|
||||
authorisationDate: null,
|
||||
source: "openfda" as const,
|
||||
packageOptions,
|
||||
};
|
||||
|
||||
render(
|
||||
<MobileEditModal
|
||||
{...defaultProps}
|
||||
medicationEnrichment={createMedicationEnrichmentState({
|
||||
query: "Ibuprofen",
|
||||
results: [result],
|
||||
})}
|
||||
onMedicationEnrichmentQueryChange={vi.fn()}
|
||||
onMedicationEnrichmentSearch={vi.fn()}
|
||||
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={vi.fn()}
|
||||
onMedicationEnrichmentPackageApply={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.details.showAction" }));
|
||||
const packageButtons = document.querySelectorAll<HTMLButtonElement>(".medication-enrichment-package-choice-button");
|
||||
expect(packageButtons).toHaveLength(2);
|
||||
fireEvent.click(packageButtons[1]);
|
||||
|
||||
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result, packageOptions[1]);
|
||||
});
|
||||
|
||||
it("groups medication start and end date fields in one stacked date pair", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ export type MedicationEnrichmentSearchResult = {
|
||||
genericStatus: MedicationEnrichmentGenericStatus;
|
||||
authorisationDate: string | null;
|
||||
source: MedicationEnrichmentSearchSource;
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentSearchResponse = {
|
||||
@@ -62,6 +63,19 @@ export type MedicationEnrichmentStrengthOption = {
|
||||
doseUnit: MedicationEnrichmentDoseUnit | null;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentPackageOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
packageType: PackageType;
|
||||
packCount: number;
|
||||
blistersPerPack: number | null;
|
||||
pillsPerBlister: number | null;
|
||||
totalPills: number | null;
|
||||
looseTablets: number | null;
|
||||
packageAmountValue: number | null;
|
||||
packageAmountUnit: PackageAmountUnit | null;
|
||||
};
|
||||
|
||||
export type MedicationEnrichmentEnrichResponse = {
|
||||
selection: {
|
||||
name: string;
|
||||
@@ -76,6 +90,7 @@ export type MedicationEnrichmentEnrichResponse = {
|
||||
genericName: string | null;
|
||||
medicationForm: MedicationForm | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
packageOptions: MedicationEnrichmentPackageOption[];
|
||||
};
|
||||
meta: {
|
||||
rxNormMatched: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./formatters";
|
||||
export * from "./ics";
|
||||
export * from "./medication-enrichment";
|
||||
export * from "./schedule";
|
||||
export * from "./stock";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MedicationEnrichmentSearchResult } from "../types";
|
||||
|
||||
function normalizeMedicationEnrichmentGroupingText(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toUpperCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function getMedicationEnrichmentDisplayResultKey(result: MedicationEnrichmentSearchResult): string {
|
||||
if (result.source === "openfda") {
|
||||
return `openfda:${normalizeMedicationEnrichmentGroupingText(result.name)}:${normalizeMedicationEnrichmentGroupingText(result.genericName)}`;
|
||||
}
|
||||
|
||||
return result.code;
|
||||
}
|
||||
|
||||
export function countMedicationEnrichmentDisplayResults(results: MedicationEnrichmentSearchResult[]): number {
|
||||
return new Set(results.map(getMedicationEnrichmentDisplayResultKey)).size;
|
||||
}
|
||||
Reference in New Issue
Block a user