feat: add inhaler and injection package types
Closes #558 - add inhaler and injection as supported medication package types - align refill, planner, dashboard, report, export, and notification wording for the new discrete package types - include the validated CI repair for formatting and dashboard label parity
This commit is contained in:
@@ -126,13 +126,13 @@ Share your medication schedule with others via a public link.
|
||||
- Manual entry remains available
|
||||
|
||||
### Smart Inventory
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection)
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
||||
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container)
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
- One-click refill with package-aware refill options for discrete containers and amount-based packages
|
||||
- Complete refill history per medication
|
||||
- Automatic stock updates after each refill
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ type TranslationKeys = {
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
puffs: string;
|
||||
injections: string;
|
||||
units: string;
|
||||
ml: string;
|
||||
blister: string;
|
||||
@@ -211,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "The following medications are running low and should be reordered soon:",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
pills: "Pills",
|
||||
pills: "Available",
|
||||
days: "Days",
|
||||
runsOut: "Runs Out",
|
||||
},
|
||||
@@ -305,6 +307,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
puffs: "puffs",
|
||||
injections: "injections",
|
||||
units: "units",
|
||||
ml: "ml",
|
||||
blister: "blister",
|
||||
@@ -333,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
pills: "Tabletten",
|
||||
pills: "Verfuegbar",
|
||||
days: "Tage",
|
||||
runsOut: "Aufgebraucht",
|
||||
},
|
||||
@@ -430,6 +434,8 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
puffs: "Hübe",
|
||||
injections: "Injektionen",
|
||||
units: "Einheiten",
|
||||
ml: "ml",
|
||||
blister: "Blister",
|
||||
|
||||
@@ -62,7 +62,7 @@ const medicationExportSchema = z.object({
|
||||
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
||||
inventory: inventorySchema,
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
medicationEndDate: z.string().nullable().optional(),
|
||||
@@ -560,7 +560,11 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
const packageType = normalizePackageType(medication?.packageType);
|
||||
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||
const quantityAdded =
|
||||
packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"
|
||||
packageType === "bottle" ||
|
||||
packageType === "inhaler" ||
|
||||
packageType === "injection" ||
|
||||
packageType === "tube" ||
|
||||
packageType === "liquid_container"
|
||||
? (refill.loosePillsAdded ?? 0)
|
||||
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
|
||||
label: { type: "string" },
|
||||
pillWeightMg: { type: "number", nullable: true },
|
||||
doseUnit: {
|
||||
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
||||
anyOf: [
|
||||
{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -80,7 +83,7 @@ const packageOptionSchema = {
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
description: { type: "string" },
|
||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
|
||||
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
|
||||
packCount: { type: "integer", minimum: 1 },
|
||||
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import {
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
@@ -67,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
|
||||
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
||||
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
@@ -264,7 +265,7 @@ const medicationBodyOpenApiSchema = {
|
||||
totalPills: { type: ["integer", "null"], minimum: 1 },
|
||||
looseTablets: { type: "integer", minimum: 0 },
|
||||
pillWeightMg: { type: ["number", "null"], minimum: 0 },
|
||||
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] },
|
||||
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
|
||||
medicationStartDate: {
|
||||
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
|
||||
},
|
||||
@@ -1201,7 +1202,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
const packageType = normalizePackageType(existing.packageType);
|
||||
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||
const allowsBottleCapacityUpdate = packageType === "bottle";
|
||||
const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
|
||||
if (allowsAmountBaseUpdate) {
|
||||
const normalizedAmountBase = looseTablets ?? totalPills;
|
||||
if (normalizedAmountBase !== undefined) {
|
||||
@@ -1210,7 +1211,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||
}
|
||||
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
||||
if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
|
||||
updateFields.totalPills = totalPills;
|
||||
}
|
||||
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type StockReminderItem as SharedStockReminderItem,
|
||||
} from "../services/notifications/builders.js";
|
||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
|
||||
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||
import { escapeHtml, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ type SendEmailBody = {
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical?: boolean;
|
||||
@@ -567,11 +568,10 @@ ${getFooterPlain(language)}`;
|
||||
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
||||
.filter(([name]) => name.length > 0)
|
||||
);
|
||||
const filteredLowStock = lowStock.filter((item) => {
|
||||
const filteredLowStock = lowStock.flatMap((item) => {
|
||||
const packageType = activeMedicationByName.get(item.name);
|
||||
if (!packageType) return false;
|
||||
if (isTubePackageType(packageType)) return false;
|
||||
return true;
|
||||
if (!packageType || isTubePackageType(packageType)) return [];
|
||||
return [{ ...item, packageType }];
|
||||
});
|
||||
if (filteredLowStock.length === 0) {
|
||||
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||
@@ -644,7 +644,7 @@ ${getFooterPlain(language)}`;
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((r) =>
|
||||
messageParts.push(
|
||||
` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||
` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -653,7 +653,7 @@ ${getFooterPlain(language)}`;
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowStockMeds.forEach((r) =>
|
||||
messageParts.push(
|
||||
` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||
` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -734,12 +734,13 @@ ${getFooterPlain(language)}`;
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const safeName = escapeHtml(row.name);
|
||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||
const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
|
||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeQuantity}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
idParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isPackageAmountPackageType,
|
||||
normalizePackageType,
|
||||
} from "../utils/package-profiles.js";
|
||||
|
||||
const refillSchema = z
|
||||
.object({
|
||||
@@ -143,10 +148,10 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
|
||||
const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isBottle = packageType === "bottle";
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const isPackageAmountPackage = isPackageAmountPackageType(packageType);
|
||||
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
||||
const fallbackAmountPerPackage = Math.max(
|
||||
@@ -161,18 +166,11 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
const requestedPackAdds = Math.max(0, packsAdded);
|
||||
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
||||
|
||||
let effectivePacksAdded = requestedPackAdds;
|
||||
if (isBottle) {
|
||||
if (isDiscreteCountPackage) {
|
||||
effectivePacksAdded = 0;
|
||||
} else if (isCountBasedAmountPackage) {
|
||||
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
|
||||
}
|
||||
const effectiveLoosePillsAdded = isCountBasedAmountPackage
|
||||
? effectivePacksAdded * amountPerPackage
|
||||
: requestedAmountAdds;
|
||||
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
const totalPillsAdded = isAmountBased
|
||||
? effectiveLoosePillsAdded
|
||||
@@ -189,7 +187,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
if (remainingPrescriptionRefills <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
}
|
||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
}
|
||||
}
|
||||
@@ -207,10 +205,11 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||
let newTotalAmount = med.totalPills ?? med.looseTablets;
|
||||
|
||||
if (isBottle) {
|
||||
if (isDiscreteCountPackage) {
|
||||
newLooseTablets = targetCurrentStock;
|
||||
newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
|
||||
newStockAdjustment = 0;
|
||||
} else if (isCountBasedAmountPackage) {
|
||||
} else if (isPackageAmountPackage) {
|
||||
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
|
||||
newLooseTablets = targetCurrentStock;
|
||||
newTotalAmount = targetCurrentStock;
|
||||
@@ -222,7 +221,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
|
||||
let consumedRefills = 0;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||
consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
@@ -246,7 +245,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
updatedAt: refillBaselineAt,
|
||||
};
|
||||
|
||||
if (isCountBasedAmountPackage) {
|
||||
if (isPackageAmountPackage) {
|
||||
updatePayload.totalPills = newTotalAmount;
|
||||
updatePayload.packageAmountValue = amountPerPackage;
|
||||
}
|
||||
@@ -329,9 +328,9 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isBottle = packageType === "bottle";
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
|
||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
return refills.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
|
||||
import { formatPlannerQuantity } from "../planner-service.js";
|
||||
|
||||
export type StockReminderItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical?: boolean;
|
||||
@@ -47,7 +49,7 @@ export function buildStockReminderPushNotification(
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalItems.forEach((item) =>
|
||||
messageParts.push(
|
||||
` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -56,7 +58,7 @@ export function buildStockReminderPushNotification(
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowItems.forEach((item) =>
|
||||
messageParts.push(
|
||||
` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,20 @@ export function isContainerPackage(packageType?: string): boolean {
|
||||
|
||||
export function getPlannerUnit(
|
||||
packageType: string | undefined,
|
||||
tr: { common: { units: string; ml: string; pills: string } }
|
||||
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||
): string {
|
||||
const unitKind = getPlannerUnitKind(packageType);
|
||||
if (unitKind === "units") return tr.common.units;
|
||||
if (unitKind === "ml") return tr.common.ml;
|
||||
if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills;
|
||||
if (unitKind === "injections") return tr.common.injections ?? tr.common.pills;
|
||||
return tr.common.pills;
|
||||
}
|
||||
|
||||
export function formatPlannerQuantity(
|
||||
packageType: string | undefined,
|
||||
count: number,
|
||||
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
|
||||
): string {
|
||||
return `${count} ${getPlannerUnit(packageType, tr)}`;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./notifications/builders.js";
|
||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||
import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js";
|
||||
import { formatPlannerQuantity } from "./planner-service.js";
|
||||
|
||||
export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||
|
||||
@@ -108,6 +109,7 @@ function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
packageType?: string;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical: boolean;
|
||||
@@ -309,6 +311,7 @@ async function getMedicationsNeedingReminder(
|
||||
lowStock.push({
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
medsLeft: currentPills,
|
||||
packageType,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
isCritical,
|
||||
@@ -432,10 +435,11 @@ async function sendReminderEmail(
|
||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr);
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${quantityText}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
|
||||
</tr>`;
|
||||
@@ -479,7 +483,7 @@ async function sendReminderEmail(
|
||||
|
||||
${tr.stockReminder.description}
|
||||
|
||||
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
|
||||
${lowStock.map((r) => `${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
|
||||
|
||||
---
|
||||
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||
|
||||
@@ -62,13 +62,17 @@ describe("planner-service decomposition regression", () => {
|
||||
});
|
||||
|
||||
it("maps package type to expected planner units after service extraction", () => {
|
||||
const tr = { common: { units: "units", ml: "ml", pills: "pills" } };
|
||||
const tr = { common: { units: "units", ml: "ml", pills: "pills", puffs: "puffs", injections: "injections" } };
|
||||
|
||||
expect(isContainerPackage("bottle")).toBe(true);
|
||||
expect(isContainerPackage("inhaler")).toBe(true);
|
||||
expect(isContainerPackage("injection")).toBe(true);
|
||||
expect(isContainerPackage("blister")).toBe(false);
|
||||
expect(getPlannerUnit("tube", tr)).toBe("units");
|
||||
expect(getPlannerUnit("liquid_container", tr)).toBe("ml");
|
||||
expect(getPlannerUnit("bottle", tr)).toBe("pills");
|
||||
expect(getPlannerUnit("inhaler", tr)).toBe("puffs");
|
||||
expect(getPlannerUnit("injection", tr)).toBe("injections");
|
||||
expect(getPlannerUnit("blister", tr)).toBe("pills");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1962,7 +1962,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
@@ -2336,10 +2336,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(med.stockAdjustment).toBe(0);
|
||||
});
|
||||
|
||||
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
it.each([
|
||||
{
|
||||
label: "bottle",
|
||||
payload: {
|
||||
name: "Bottle Zero Reset Med",
|
||||
packageType: "bottle",
|
||||
@@ -2350,6 +2349,40 @@ describe("E2E Tests with Real Routes", () => {
|
||||
looseTablets: 20,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "inhaler",
|
||||
payload: {
|
||||
name: "Inhaler Zero Reset Med",
|
||||
packageType: "inhaler",
|
||||
doseUnit: "puffs",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 200,
|
||||
looseTablets: 40,
|
||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "injection",
|
||||
payload: {
|
||||
name: "Injection Zero Reset Med",
|
||||
packageType: "injection",
|
||||
doseUnit: "injections",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 12,
|
||||
looseTablets: 4,
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
])("should persist $label zero reset with packCount 0 and zero totals", async ({ payload }) => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload,
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const medId = createResponse.json().id;
|
||||
@@ -2495,7 +2528,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 150, quantityAdded: 150 },
|
||||
});
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
@@ -3125,6 +3158,39 @@ describe("E2E Tests with Real Routes", () => {
|
||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
};
|
||||
|
||||
const discreteContainerMedications = [
|
||||
{
|
||||
label: "inhaler",
|
||||
payload: {
|
||||
name: "Asthma Inhaler",
|
||||
packageType: "inhaler",
|
||||
doseUnit: "puffs",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 200,
|
||||
looseTablets: 200,
|
||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
expectedDoseUnit: "puffs",
|
||||
},
|
||||
{
|
||||
label: "injection",
|
||||
payload: {
|
||||
name: "B12 Injection",
|
||||
packageType: "injection",
|
||||
doseUnit: "injections",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 12,
|
||||
looseTablets: 12,
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
expectedDoseUnit: "injections",
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
@@ -3225,6 +3291,23 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.looseTablets).toBe(180);
|
||||
});
|
||||
|
||||
it.each(discreteContainerMedications)("should create and return $label type medication", async ({
|
||||
payload,
|
||||
expectedDoseUnit,
|
||||
}) => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.packageType).toBe(payload.packageType);
|
||||
expect(data.doseUnit).toBe(expectedDoseUnit);
|
||||
expect(data.looseTablets).toBe(payload.looseTablets);
|
||||
});
|
||||
|
||||
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -3407,7 +3490,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
looseTablets: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 100, quantityAdded: 100 },
|
||||
expectedVisibleStockBeforeRefill: 10,
|
||||
expectedQuantityAdded: 100,
|
||||
expectedResponsePacksAdded: 1,
|
||||
@@ -3522,7 +3605,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 80, quantityAdded: 80 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
@@ -3602,7 +3685,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
@@ -3649,7 +3732,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 5, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 5, loosePillsAdded: 750, quantityAdded: 750 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
@@ -3689,7 +3772,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionRemainingRefills: 2,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedLooseAdded: 180,
|
||||
@@ -3706,7 +3789,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionRemainingRefills: 3,
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||
refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 80, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 80,
|
||||
expectedPacksAdded: 2,
|
||||
expectedLooseAdded: 80,
|
||||
@@ -3786,7 +3869,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refillResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 },
|
||||
});
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
|
||||
|
||||
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||
|
||||
@@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
|
||||
return normalizePackageType(packageType) === "liquid_container";
|
||||
}
|
||||
|
||||
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||
export function isPackageAmountPackageType(packageType?: string | null): boolean {
|
||||
const normalized = normalizePackageType(packageType);
|
||||
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
|
||||
return normalized === "tube" || normalized === "liquid_container";
|
||||
}
|
||||
|
||||
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
|
||||
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
|
||||
const normalized = normalizePackageType(packageType);
|
||||
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
|
||||
}
|
||||
|
||||
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||
return isPackageAmountPackageType(packageType) || isDiscreteCountPackageType(packageType);
|
||||
}
|
||||
|
||||
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" | "puffs" | "injections" {
|
||||
const normalized = normalizePackageType(packageType);
|
||||
if (normalized === "tube") return "units";
|
||||
if (normalized === "liquid_container") return "ml";
|
||||
if (normalized === "inhaler") return "puffs";
|
||||
if (normalized === "injection") return "injections";
|
||||
return "pills";
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ export function MedDetailModal({
|
||||
if (!selectedMed) return null;
|
||||
const isAmountPackage =
|
||||
isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType);
|
||||
const getDiscreteUnitLabel = (value: number) => {
|
||||
if (selectedMed.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (selectedMed.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
|
||||
return value === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
const amountUnitLabel =
|
||||
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
@@ -266,7 +271,7 @@ export function MedDetailModal({
|
||||
if (isTubePackageType(selectedMed.packageType)) {
|
||||
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||
}
|
||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
return `${usage} ${getDiscreteUnitLabel(usage)}`;
|
||||
};
|
||||
const scheduleIntakes = getMedicationIntakes(selectedMed);
|
||||
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
|
||||
@@ -694,18 +699,14 @@ export function MedDetailModal({
|
||||
<span>{t("editStock.currentTotal")}:</span>
|
||||
<span>
|
||||
{currentTotal}
|
||||
{isAmountPackage
|
||||
? ` ${stockUnitLabel}`
|
||||
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(currentTotal)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-row">
|
||||
<span>{t("editStock.newTotal")}:</span>
|
||||
<span>
|
||||
{newTotal}
|
||||
{isAmountPackage
|
||||
? ` ${stockUnitLabel}`
|
||||
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(newTotal)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`summary-row difference ${differenceClass}`}>
|
||||
@@ -713,9 +714,7 @@ export function MedDetailModal({
|
||||
<span>
|
||||
{difference > 0 ? "+" : ""}
|
||||
{difference}
|
||||
{isAmountPackage
|
||||
? ` ${stockUnitLabel}`
|
||||
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(Math.abs(difference))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1106,7 +1105,7 @@ export function MedDetailModal({
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
const total = entry.quantityAdded;
|
||||
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(total)}`}`;
|
||||
})()}
|
||||
{entry.usedPrescription && (
|
||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||
@@ -1312,9 +1311,7 @@ export function MedDetailModal({
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill}
|
||||
{isAmountPackage
|
||||
? ` ${stockUnitLabel}`
|
||||
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(totalRefill)}`}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
@@ -79,6 +79,10 @@ function getPackageContainerTranslationKey(packageType: MedicationEnrichmentPack
|
||||
return "form.enrichment.packageContainers.blister";
|
||||
case "bottle":
|
||||
return "form.enrichment.packageContainers.bottle";
|
||||
case "inhaler":
|
||||
return "form.enrichment.packageContainers.inhaler";
|
||||
case "injection":
|
||||
return "form.enrichment.packageContainers.injection";
|
||||
case "liquid_container":
|
||||
return "form.enrichment.packageContainers.liquidContainer";
|
||||
case "tube":
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
allowsPillFormSelection,
|
||||
DOSE_UNITS,
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isPackageAmountPackageType,
|
||||
isTubePackageType,
|
||||
PACKAGE_PROFILES,
|
||||
} from "../types";
|
||||
@@ -193,6 +195,15 @@ export function MobileEditModal({
|
||||
return form.pillForm === "tablet";
|
||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||
|
||||
const getDiscreteUnitLabel = useCallback(
|
||||
(count: number) => {
|
||||
if (form.packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (form.packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
},
|
||||
[form.packageType, t]
|
||||
);
|
||||
|
||||
const getUsageLabel = useCallback(
|
||||
(intake: (typeof form.intakes)[number]) => {
|
||||
if (isLiquidContainerPackageType(form.packageType)) {
|
||||
@@ -203,16 +214,29 @@ export function MobileEditModal({
|
||||
if (isTubePackageType(form.packageType)) {
|
||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||
}
|
||||
if (form.packageType === "inhaler") return t("common.puffs");
|
||||
if (form.packageType === "injection") return t("common.injections");
|
||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||
return t("form.blisters.usageTablets");
|
||||
},
|
||||
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||
);
|
||||
|
||||
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
|
||||
const usesAmountLabels = isPackageAmountPackageType(form.packageType);
|
||||
const usesCountLabels = isDiscreteCountPackageType(form.packageType) && form.packageType !== "bottle";
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
let currentStockLabel = t("form.currentPills");
|
||||
if (usesAmountLabels) {
|
||||
currentStockLabel = t("form.currentAmount");
|
||||
} else if (usesCountLabels) {
|
||||
currentStockLabel = t("form.currentStockCount");
|
||||
}
|
||||
let totalLabel = t("form.total");
|
||||
if (usesAmountLabels) {
|
||||
totalLabel = t("form.totalAmountLabel");
|
||||
} else if (usesCountLabels) {
|
||||
totalLabel = t("form.totalCount");
|
||||
}
|
||||
const weekdayOptions = useMemo(
|
||||
() =>
|
||||
WEEKDAY_CODES.map((day) => ({
|
||||
@@ -816,7 +840,7 @@ export function MobileEditModal({
|
||||
<div className="stock-total-field">
|
||||
<p className="sub">
|
||||
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
|
||||
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||
{` ${getDiscreteUnitLabel(deriveTotalFromForm(form))}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,11 +307,17 @@ function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
||||
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
||||
}
|
||||
|
||||
function getDiscreteUnitText(med: Medication, value: number, t: TFn): string {
|
||||
if (med.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (med.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
|
||||
return value === 1 ? t("common.pill") : t("common.pills");
|
||||
}
|
||||
|
||||
function getUsageText(med: Medication, usage: number, t: TFn): string {
|
||||
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||
return `${usage} ${t(getTubeUnitKey(med))}`;
|
||||
}
|
||||
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
return `${usage} ${getDiscreteUnitText(med, usage, t)}`;
|
||||
}
|
||||
|
||||
function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
||||
@@ -325,12 +331,14 @@ function getCurrentStockText(med: Medication, t: TFn): string {
|
||||
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||
return `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
|
||||
}
|
||||
return `${getMedTotal(med)} ${t("common.pills")}`;
|
||||
return `${getMedTotal(med)} ${getDiscreteUnitText(med, getMedTotal(med), t)}`;
|
||||
}
|
||||
|
||||
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||
if (isTubePackageType(med.packageType)) return t("report.docTube");
|
||||
if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer");
|
||||
if (med.packageType === "inhaler") return t("form.packageTypeInhaler");
|
||||
if (med.packageType === "injection") return t("form.packageTypeInjection");
|
||||
if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle");
|
||||
return t("report.docBlister");
|
||||
}
|
||||
@@ -442,7 +450,11 @@ function generateTextReport(
|
||||
if (data.refills.length > 0) {
|
||||
lines.push(h3(t("report.docRefillHistory")));
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${
|
||||
isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)
|
||||
? t(getTubeUnitKey(med))
|
||||
: getDiscreteUnitText(med, r.quantityAdded, t)
|
||||
}`;
|
||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
@@ -648,7 +660,11 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(
|
||||
isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)
|
||||
? t(getTubeUnitKey(med))
|
||||
: getDiscreteUnitText(med, r.quantityAdded, t)
|
||||
)}`;
|
||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export function UserFilterModal({
|
||||
);
|
||||
};
|
||||
|
||||
const getDiscreteUnitLabel = (med: Medication, count: number): string => {
|
||||
if (med.packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (med.packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
|
||||
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
|
||||
@@ -49,7 +55,7 @@ export function UserFilterModal({
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
|
||||
}
|
||||
return `${formatNumber(usage)} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
return `${formatNumber(usage)} ${getDiscreteUnitLabel(med, usage)}`;
|
||||
};
|
||||
|
||||
const formatStockSummaryLabel = (med: Medication, currentStock: number, packageSize: number): string => {
|
||||
@@ -59,7 +65,7 @@ export function UserFilterModal({
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitG")}`;
|
||||
}
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${packageSize === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${getDiscreteUnitLabel(med, packageSize)}`;
|
||||
};
|
||||
|
||||
useEscapeKey(!!selectedUser, onClose);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntakeUnit } from "../../types";
|
||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../../types";
|
||||
import { isLiquidContainerPackageType, isTubePackageType } from "../../types";
|
||||
import { formatNumber } from "../../utils/formatters";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
|
||||
|
||||
@@ -27,6 +27,12 @@ function getTubeUnitLabel(med: MedicationLike, value: number, t: Translate): str
|
||||
return t("form.blisters.applications", { count: Math.abs(value) });
|
||||
}
|
||||
|
||||
function getDiscreteUnitLabel(med: MedicationLike, value: number, t: Translate): string {
|
||||
if (med?.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (med?.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
|
||||
return value === 1 ? t("common.pill") : t("common.pills");
|
||||
}
|
||||
|
||||
export function formatScheduleDoseUsageLabel(
|
||||
med: MedicationLike,
|
||||
usage: number,
|
||||
@@ -41,7 +47,7 @@ export function formatScheduleDoseUsageLabel(
|
||||
return `${usage} ${getTubeUnitLabel(med, usage, t)}`;
|
||||
}
|
||||
|
||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
return `${usage} ${getDiscreteUnitLabel(med, usage, t)}`;
|
||||
}
|
||||
|
||||
export function formatScheduleTotalUsageLabel(
|
||||
@@ -77,9 +83,5 @@ export function formatScheduleTotalUsageLabel(
|
||||
return `${total} ${getTubeUnitLabel(med, total, t)}`;
|
||||
}
|
||||
|
||||
if (allowsPillFormSelection(med?.packageType)) {
|
||||
return t("common.pillsTotal", { count: total });
|
||||
}
|
||||
|
||||
return t("common.pillsTotal", { count: total });
|
||||
return `${total} ${getDiscreteUnitLabel(med, total, t)}`;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from
|
||||
import {
|
||||
FIELD_LIMITS,
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isPackageAmountPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
} from "../types";
|
||||
@@ -244,7 +246,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||
const packageType = normalizePackageType(med.packageType);
|
||||
const isTubeOrLiquidPackage = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||
const isTubeOrLiquidPackage = isPackageAmountPackageType(packageType);
|
||||
let normalizedPackCount = String(med.packCount);
|
||||
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
|
||||
|
||||
@@ -288,6 +290,15 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
} else if (isLiquidContainerPackageType(packageType)) {
|
||||
normalizedPackageAmountUnit = "ml";
|
||||
}
|
||||
let resolvedDoseUnit = med.doseUnit ?? "mg";
|
||||
if (!med.doseUnit) {
|
||||
if (packageType === "inhaler") {
|
||||
resolvedDoseUnit = "puffs";
|
||||
} else if (packageType === "injection") {
|
||||
resolvedDoseUnit = "injections";
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedTotalPills = bottleTotalPills;
|
||||
if (normalizedDerivedTotal != null) {
|
||||
resolvedTotalPills = String(normalizedDerivedTotal);
|
||||
@@ -310,7 +321,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
totalPills: resolvedTotalPills,
|
||||
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
doseUnit: resolvedDoseUnit,
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
medicationEndDate: med.medicationEndDate ?? "",
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
@@ -373,9 +384,22 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
next.doseUnit = "ml";
|
||||
next.packageAmountUnit = "ml";
|
||||
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
|
||||
} else if (nextPackageType === "inhaler") {
|
||||
next.medicationForm = "tablet";
|
||||
next.pillForm = "tablet";
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
next.doseUnit = "puffs";
|
||||
} else if (nextPackageType === "injection") {
|
||||
next.medicationForm = "tablet";
|
||||
next.pillForm = "tablet";
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
next.doseUnit = "injections";
|
||||
} else {
|
||||
next.medicationForm = next.pillForm;
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
if (next.doseUnit === "puffs" || next.doseUnit === "injections") {
|
||||
next.doseUnit = "mg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +423,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (isLiquidContainerPackageType(next.packageType)) {
|
||||
next.packageAmountUnit = "ml";
|
||||
} else if (isDiscreteCountPackageType(next.packageType)) {
|
||||
next.packageAmountUnit = "ml";
|
||||
}
|
||||
|
||||
if (key === "pillForm" && value === "capsule") {
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
getMedTotal,
|
||||
getPackageSize,
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isPackageAmountPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
|
||||
@@ -172,6 +174,8 @@ export function useRefill(): UseRefillReturn {
|
||||
const isTubePackage = isTubePackageType(selectedMed.packageType);
|
||||
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
|
||||
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
|
||||
const isPackageAmountPackage = isPackageAmountPackageType(selectedMed.packageType);
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(selectedMed.packageType);
|
||||
const liquidAmountPerBottle = Math.max(
|
||||
1,
|
||||
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
|
||||
@@ -228,9 +232,9 @@ export function useRefill(): UseRefillReturn {
|
||||
let baseTotal: number;
|
||||
if (isLiquidPackage) {
|
||||
baseTotal = liquidStructuralMax;
|
||||
} else if (selectedMed.packageType === "bottle") {
|
||||
} else if (isDiscreteCountPackage) {
|
||||
baseTotal = selectedMed.looseTablets;
|
||||
} else if (isAmountPackage) {
|
||||
} else if (isPackageAmountPackage) {
|
||||
baseTotal = getPackageSize(selectedMed);
|
||||
} else {
|
||||
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||
@@ -253,10 +257,10 @@ export function useRefill(): UseRefillReturn {
|
||||
patchBody.stockAdjustment = 0;
|
||||
patchBody.packCount = 0;
|
||||
patchBody.looseTablets = 0;
|
||||
if (selectedMed.packageType === "bottle" || isAmountPackage) {
|
||||
if (isDiscreteCountPackage || isAmountPackage) {
|
||||
patchBody.totalPills = 0;
|
||||
}
|
||||
if (isTubePackage || isLiquidPackage) {
|
||||
if (isPackageAmountPackage) {
|
||||
patchBody.packageAmountValue = 0;
|
||||
}
|
||||
} else if (isTubePackage) {
|
||||
@@ -316,6 +320,7 @@ export function useRefill(): UseRefillReturn {
|
||||
if (!selectedMed) return;
|
||||
setEditStockMedication(selectedMed);
|
||||
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
|
||||
const isDiscreteCountPackage = isDiscreteCountPackageType(selectedMed.packageType);
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
@@ -338,7 +343,7 @@ export function useRefill(): UseRefillReturn {
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setEditStockLoosePills(isAmountPackage ? 0 : knownLoose);
|
||||
setEditStockLoosePills(isAmountPackage || isDiscreteCountPackage ? 0 : knownLoose);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: "editStock" }, "");
|
||||
}, []);
|
||||
|
||||
@@ -185,6 +185,8 @@
|
||||
"packageTypeBottle": "Pillendose",
|
||||
"packageTypeTube": "Tube",
|
||||
"packageTypeLiquidContainer": "Flüssigbehältnis",
|
||||
"packageTypeInhaler": "Inhalator",
|
||||
"packageTypeInjection": "Injektion",
|
||||
"packs": "Packungen",
|
||||
"bottles": "Flaschen",
|
||||
"tubes": "Tuben",
|
||||
@@ -192,9 +194,11 @@
|
||||
"pillsPerBlister": "Tabletten pro Blister",
|
||||
"totalCapacity": "Gesamtkapazität",
|
||||
"currentPills": "Aktuelle Tabletten",
|
||||
"currentStockCount": "Aktueller Bestand",
|
||||
"totalAmount": "Gesamtmenge",
|
||||
"currentAmount": "Aktuelle Menge",
|
||||
"totalAmountLabel": "Gesamt (Menge)",
|
||||
"totalCount": "Gesamt (Anzahl)",
|
||||
"packageAmount": "Packungsinhalt",
|
||||
"packageAmountPerBottle": "Inhalt pro Flasche",
|
||||
"packageAmountPerTube": "Inhalt pro Tube",
|
||||
@@ -261,6 +265,10 @@
|
||||
"blister_other": "{{count}} Blisterpackungen",
|
||||
"bottle_one": "1 Flasche",
|
||||
"bottle_other": "{{count}} Flaschen",
|
||||
"inhaler_one": "1 Inhalator",
|
||||
"inhaler_other": "{{count}} Inhalatoren",
|
||||
"injection_one": "1 Injektionspackung",
|
||||
"injection_other": "{{count}} Injektionspackungen",
|
||||
"liquidContainer_one": "1 Flasche",
|
||||
"liquidContainer_other": "{{count}} Flaschen",
|
||||
"tube_one": "1 Tube",
|
||||
@@ -636,6 +644,10 @@
|
||||
"optional": "optional",
|
||||
"pill": "Tablette",
|
||||
"pills": "Tabletten",
|
||||
"puff": "Hub",
|
||||
"puffs": "Hübe",
|
||||
"injection": "Injektion",
|
||||
"injections": "Injektionen",
|
||||
"of": "von",
|
||||
"loose": "lose",
|
||||
"none": "Kein",
|
||||
|
||||
@@ -185,6 +185,8 @@
|
||||
"packageTypeBottle": "Pill Bottle",
|
||||
"packageTypeTube": "Tube",
|
||||
"packageTypeLiquidContainer": "Liquid Container",
|
||||
"packageTypeInhaler": "Inhaler",
|
||||
"packageTypeInjection": "Injection",
|
||||
"packs": "Packs",
|
||||
"bottles": "Bottles",
|
||||
"tubes": "Tubes",
|
||||
@@ -192,9 +194,11 @@
|
||||
"pillsPerBlister": "Pills per blister",
|
||||
"totalCapacity": "Total Capacity",
|
||||
"currentPills": "Current Pills",
|
||||
"currentStockCount": "Current Stock",
|
||||
"totalAmount": "Total Amount",
|
||||
"currentAmount": "Current Amount",
|
||||
"totalAmountLabel": "Total (amount)",
|
||||
"totalCount": "Total (count)",
|
||||
"packageAmount": "Package amount",
|
||||
"packageAmountPerBottle": "Amount per bottle",
|
||||
"packageAmountPerTube": "Amount per tube",
|
||||
@@ -261,6 +265,10 @@
|
||||
"blister_other": "{{count}} blister packs",
|
||||
"bottle_one": "1 bottle",
|
||||
"bottle_other": "{{count}} bottles",
|
||||
"inhaler_one": "1 inhaler",
|
||||
"inhaler_other": "{{count}} inhalers",
|
||||
"injection_one": "1 injection pack",
|
||||
"injection_other": "{{count}} injection packs",
|
||||
"liquidContainer_one": "1 bottle",
|
||||
"liquidContainer_other": "{{count}} bottles",
|
||||
"tube_one": "1 tube",
|
||||
@@ -636,6 +644,10 @@
|
||||
"optional": "optional",
|
||||
"pill": "pill",
|
||||
"pills": "pills",
|
||||
"puff": "puff",
|
||||
"puffs": "puffs",
|
||||
"injection": "injection",
|
||||
"injections": "injections",
|
||||
"of": "of",
|
||||
"loose": "loose",
|
||||
"none": "None",
|
||||
|
||||
@@ -435,6 +435,12 @@ export function DashboardPage() {
|
||||
setObsoleteCandidate(null);
|
||||
};
|
||||
|
||||
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
|
||||
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
|
||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
@@ -449,7 +455,11 @@ export function DashboardPage() {
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return `${formatNumber(medsLeft)} ${getTubeStockUnitLabel()}`;
|
||||
}
|
||||
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||
const roundedCount = Math.round(medsLeft);
|
||||
if (med?.packageType !== "inhaler" && med?.packageType !== "injection") {
|
||||
return t("table.pillsCount", { count: roundedCount });
|
||||
}
|
||||
return `${roundedCount} ${getDiscreteUnitLabel(med?.packageType, roundedCount)}`;
|
||||
};
|
||||
|
||||
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
|
||||
@@ -477,7 +487,7 @@ export function DashboardPage() {
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
||||
}
|
||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
return `${usage} ${getDiscreteUnitLabel(med?.packageType, usage)}`;
|
||||
};
|
||||
|
||||
const formatTotalUsageLabel = (
|
||||
@@ -511,6 +521,9 @@ export function DashboardPage() {
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||
}
|
||||
if (med?.packageType === "inhaler" || med?.packageType === "injection") {
|
||||
return `${total} ${getDiscreteUnitLabel(med.packageType, total)}`;
|
||||
}
|
||||
return t("common.pillsTotal", { count: total });
|
||||
};
|
||||
|
||||
@@ -551,7 +564,7 @@ export function DashboardPage() {
|
||||
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: tubeUnit });
|
||||
}
|
||||
|
||||
const pillUnit = dailyTotal === 1 ? t("common.pill") : t("common.pills");
|
||||
const pillUnit = getDiscreteUnitLabel(med.packageType, dailyTotal);
|
||||
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: pillUnit });
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ import {
|
||||
getPackageProfile,
|
||||
getPackageSize,
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isPackageAmountPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
PACKAGE_PROFILES,
|
||||
@@ -65,7 +67,16 @@ const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s
|
||||
|
||||
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
|
||||
if (unit === "IU") return "units";
|
||||
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
|
||||
if (
|
||||
unit === "mg" ||
|
||||
unit === "g" ||
|
||||
unit === "mcg" ||
|
||||
unit === "ml" ||
|
||||
unit === "units" ||
|
||||
unit === "puffs" ||
|
||||
unit === "injections"
|
||||
)
|
||||
return unit;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -768,6 +779,15 @@ export function MedicationsPage() {
|
||||
return form.pillForm === "tablet";
|
||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||
|
||||
const getDiscreteUnitLabel = useCallback(
|
||||
(packageType: PackageType, count: number) => {
|
||||
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const getUsageLabel = useCallback(
|
||||
(intakeUnit: "ml" | "tsp" | "tbsp") => {
|
||||
if (isLiquidContainerPackageType(form.packageType)) {
|
||||
@@ -778,16 +798,29 @@ export function MedicationsPage() {
|
||||
if (isTubePackageType(form.packageType)) {
|
||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||
}
|
||||
if (form.packageType === "inhaler") return t("common.puffs");
|
||||
if (form.packageType === "injection") return t("common.injections");
|
||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||
return t("form.blisters.usageTablets");
|
||||
},
|
||||
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||
);
|
||||
|
||||
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
|
||||
const usesAmountLabels = isPackageAmountPackageType(form.packageType);
|
||||
const usesCountLabels = isDiscreteCountPackageType(form.packageType) && form.packageType !== "bottle";
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
let currentStockLabel = t("form.currentPills");
|
||||
if (usesAmountLabels) {
|
||||
currentStockLabel = t("form.currentAmount");
|
||||
} else if (usesCountLabels) {
|
||||
currentStockLabel = t("form.currentStockCount");
|
||||
}
|
||||
let totalLabel = t("form.total");
|
||||
if (usesAmountLabels) {
|
||||
totalLabel = t("form.totalAmountLabel");
|
||||
} else if (usesCountLabels) {
|
||||
totalLabel = t("form.totalCount");
|
||||
}
|
||||
const weekdayOptions = useMemo(
|
||||
() =>
|
||||
WEEKDAY_CODES.map((day) => ({
|
||||
@@ -818,9 +851,9 @@ export function MedicationsPage() {
|
||||
(med: Medication) => {
|
||||
if (isTubePackageType(med.packageType)) return "";
|
||||
if (isLiquidContainerPackageType(med.packageType)) return " ml";
|
||||
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
return ` ${getDiscreteUnitLabel(normalizePackageType(med.packageType), getPackageSize(med))}`;
|
||||
},
|
||||
[t]
|
||||
[getDiscreteUnitLabel]
|
||||
);
|
||||
|
||||
const getMedicationUsageUnitLabel = useCallback(
|
||||
@@ -829,10 +862,9 @@ export function MedicationsPage() {
|
||||
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
|
||||
}
|
||||
if (isLiquidContainerPackageType(med.packageType)) return "ml";
|
||||
if (usage === 1) return t("common.pill");
|
||||
return t("common.pills");
|
||||
return getDiscreteUnitLabel(normalizePackageType(med.packageType), usage);
|
||||
},
|
||||
[t]
|
||||
[getDiscreteUnitLabel, t]
|
||||
);
|
||||
|
||||
const clearMedicationLinkParams = useCallback(() => {
|
||||
@@ -889,7 +921,7 @@ export function MedicationsPage() {
|
||||
setReadOnlyView(false);
|
||||
pendingAction();
|
||||
} else if (source === "mobile-edit" && showEditModal) {
|
||||
clearEditMedIdParam();
|
||||
clearMedicationLinkParams();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
@@ -1059,6 +1091,8 @@ export function MedicationsPage() {
|
||||
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
|
||||
} else if (isLiquidContainerPackageType(form.packageType)) {
|
||||
derivedMedicationForm = "liquid";
|
||||
} else if (isDiscreteCountPackageType(form.packageType)) {
|
||||
derivedMedicationForm = "tablet";
|
||||
} else {
|
||||
derivedMedicationForm = form.pillForm;
|
||||
}
|
||||
@@ -1079,8 +1113,7 @@ export function MedicationsPage() {
|
||||
genericName: form.genericName.trim() || null,
|
||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||
medicationForm: derivedMedicationForm,
|
||||
pillForm:
|
||||
isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType) ? null : form.pillForm,
|
||||
pillForm: allowsPillFormSelection(form.packageType) ? form.pillForm : null,
|
||||
lifecycleCategory: form.lifecycleCategory,
|
||||
packageType: normalizePackageType(form.packageType),
|
||||
packCount: isTubePackageType(form.packageType)
|
||||
|
||||
@@ -122,6 +122,12 @@ export function PlannerPage() {
|
||||
const canSendNotification =
|
||||
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
|
||||
|
||||
const getDiscreteUnitLabel = (packageType: string | undefined, count: number): string => {
|
||||
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
|
||||
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
};
|
||||
|
||||
const getUsageUnitLabel = (medicationId: number, count: number): string => {
|
||||
const med = meds.find((m) => m.id === medicationId);
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
@@ -130,7 +136,7 @@ export function PlannerPage() {
|
||||
if (isTubePackageType(med?.packageType)) {
|
||||
return med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
}
|
||||
return count === 1 ? t("common.pill") : t("common.pills");
|
||||
return getDiscreteUnitLabel(med?.packageType, count);
|
||||
};
|
||||
|
||||
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
|
||||
@@ -143,7 +149,7 @@ export function PlannerPage() {
|
||||
const unit = med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||
return `${roundedLoose} ${unit}`;
|
||||
}
|
||||
return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
return `${roundedLoose} ${getDiscreteUnitLabel(med?.packageType, roundedLoose)}`;
|
||||
};
|
||||
|
||||
async function sendPlannerNotification() {
|
||||
|
||||
@@ -163,6 +163,59 @@ describe("ReportModal", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exports injection refill history with injection unit wording", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: null,
|
||||
lastDoseAt: null,
|
||||
refills: [
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 3,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
packageType: "injection",
|
||||
totalPills: 6,
|
||||
looseTablets: 6,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
const content = await (blob as Blob).text();
|
||||
|
||||
expect(content).toContain("report.docCurrentStock: 6 common.injections");
|
||||
expect(content).toContain("+3 common.injections");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates printable report when PDF format is selected", async () => {
|
||||
const onClose = vi.fn();
|
||||
const mockWrite = vi.fn();
|
||||
|
||||
@@ -15,8 +15,14 @@ const t = (key: string, options?: Record<string, unknown>): string => {
|
||||
return "pill";
|
||||
case "common.pills":
|
||||
return "pills";
|
||||
case "common.pillsTotal":
|
||||
return `${options?.count ?? 0} pills total`;
|
||||
case "common.puff":
|
||||
return "puff";
|
||||
case "common.puffs":
|
||||
return "puffs";
|
||||
case "common.injection":
|
||||
return "injection";
|
||||
case "common.injections":
|
||||
return "injections";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
@@ -33,6 +39,13 @@ describe("schedule formatters", () => {
|
||||
expect(formatScheduleDoseUsageLabel({ packageType: "tube", medicationForm: "liquid" }, 3, t)).toBe("3 ml");
|
||||
});
|
||||
|
||||
it("formats inhaler and injection doses with package-specific unit wording", () => {
|
||||
expect(formatScheduleDoseUsageLabel({ packageType: "inhaler" }, 1, t)).toBe("1 puff");
|
||||
expect(formatScheduleDoseUsageLabel({ packageType: "inhaler" }, 2, t)).toBe("2 puffs");
|
||||
expect(formatScheduleDoseUsageLabel({ packageType: "injection" }, 1, t)).toBe("1 injection");
|
||||
expect(formatScheduleDoseUsageLabel({ packageType: "injection" }, 3, t)).toBe("3 injections");
|
||||
});
|
||||
|
||||
it("formats liquid totals from dose units and mixed-unit conversion", () => {
|
||||
expect(
|
||||
formatScheduleTotalUsageLabel(
|
||||
@@ -71,6 +84,8 @@ describe("schedule formatters", () => {
|
||||
"tbsp"
|
||||
)
|
||||
).toBe("4 tablespoons 60 ml");
|
||||
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills total");
|
||||
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills");
|
||||
expect(formatScheduleTotalUsageLabel({ packageType: "inhaler" }, 4, t)).toBe("4 puffs");
|
||||
expect(formatScheduleTotalUsageLabel({ packageType: "injection" }, 2, t)).toBe("2 injections");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +199,24 @@ describe("useMedicationForm", () => {
|
||||
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ packageType: "inhaler" as const, expectedDoseUnit: "puffs" },
|
||||
{ packageType: "injection" as const, expectedDoseUnit: "injections" },
|
||||
])("enforces discrete container defaults when packageType is $packageType", ({ packageType, expectedDoseUnit }) => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
act(() => {
|
||||
result.current.handleValueChange("packageType", packageType);
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe(packageType);
|
||||
expect(result.current.form.medicationForm).toBe("tablet");
|
||||
expect(result.current.form.pillForm).toBe("tablet");
|
||||
expect(result.current.form.lifecycleCategory).toBe("refill_when_empty");
|
||||
expect(result.current.form.doseUnit).toBe(expectedDoseUnit);
|
||||
expect(result.current.form.packageAmountUnit).toBe("ml");
|
||||
});
|
||||
|
||||
it("normalizes legacy tube records to grams in startEdit", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
const openEditModal = vi.fn();
|
||||
@@ -227,6 +245,41 @@ describe("useMedicationForm", () => {
|
||||
expect(result.current.form.packageAmountUnit).toBe("g");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ packageType: "inhaler" as const, totalPills: 200, looseTablets: 120, expectedDoseUnit: "puffs" },
|
||||
{ packageType: "injection" as const, totalPills: 12, looseTablets: 6, expectedDoseUnit: "injections" },
|
||||
])("assigns $expectedDoseUnit when editing $packageType records without a stored dose unit", ({
|
||||
packageType,
|
||||
totalPills,
|
||||
looseTablets,
|
||||
expectedDoseUnit,
|
||||
}) => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
const openEditModal = vi.fn();
|
||||
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
|
||||
|
||||
const med: Medication = {
|
||||
id: 13,
|
||||
name: `${packageType} med`,
|
||||
takenBy: [],
|
||||
packageType,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills,
|
||||
looseTablets,
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.startEdit(med, openEditModal);
|
||||
});
|
||||
|
||||
expect(result.current.form.packageType).toBe(packageType);
|
||||
expect(result.current.form.doseUnit).toBe(expectedDoseUnit);
|
||||
});
|
||||
|
||||
it("adds, edits and removes blister rows", () => {
|
||||
const { result } = renderHook(() => useMedicationForm());
|
||||
|
||||
|
||||
@@ -388,18 +388,28 @@ describe("useRefill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resets bottle stock correction payload to zero base fields", async () => {
|
||||
it.each([
|
||||
{ id: 9, packageType: "bottle" as const, name: "Zero Reset Bottle", totalPills: 100, looseTablets: 20 },
|
||||
{ id: 10, packageType: "inhaler" as const, name: "Zero Reset Inhaler", totalPills: 200, looseTablets: 40 },
|
||||
{ id: 11, packageType: "injection" as const, name: "Zero Reset Injection", totalPills: 12, looseTablets: 4 },
|
||||
])("resets $packageType stock correction payload to zero base fields", async ({
|
||||
id,
|
||||
packageType,
|
||||
name,
|
||||
totalPills,
|
||||
looseTablets,
|
||||
}) => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const bottleMed: Medication = {
|
||||
id: 9,
|
||||
name: "Zero Reset Bottle",
|
||||
packageType: "bottle",
|
||||
const med: Medication = {
|
||||
id,
|
||||
name,
|
||||
packageType,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
totalPills,
|
||||
looseTablets,
|
||||
stockAdjustment: 5,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
@@ -410,15 +420,15 @@ describe("useRefill", () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(bottleMed, {
|
||||
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||
result.current.openEditStockModal(med, {
|
||||
all: [{ name, medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||
});
|
||||
result.current.setEditStockFullBlisters(0);
|
||||
result.current.setEditStockPartialBlisterPills(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
|
||||
await result.current.submitStockCorrection(id, med, mockLoadMeds);
|
||||
});
|
||||
|
||||
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
|
||||
@@ -7,7 +7,9 @@ export {
|
||||
allowsPillFormSelection,
|
||||
getPackageProfile,
|
||||
isAmountBasedPackageType,
|
||||
isDiscreteCountPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isPackageAmountPackageType,
|
||||
isTubePackageType,
|
||||
normalizePackageType,
|
||||
PACKAGE_PROFILES,
|
||||
@@ -15,10 +17,10 @@ export {
|
||||
} from "./package-profiles";
|
||||
|
||||
import type { PackageType } from "./package-profiles";
|
||||
import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "./package-profiles";
|
||||
import { isDiscreteCountPackageType, isPackageAmountPackageType } from "./package-profiles";
|
||||
|
||||
// Common medication dose units
|
||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units" | "puffs" | "injections";
|
||||
export type ScheduleMode = "interval" | "weekdays";
|
||||
export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
||||
|
||||
@@ -106,6 +108,8 @@ export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||
{ value: "mcg", label: "mcg (µg)" },
|
||||
{ value: "ml", label: "ml" },
|
||||
{ value: "units", label: "units" },
|
||||
{ value: "puffs", label: "puffs" },
|
||||
{ value: "injections", label: "injections" },
|
||||
];
|
||||
|
||||
export type Blister = {
|
||||
@@ -406,14 +410,14 @@ type MedLike = Pick<
|
||||
|
||||
/** Calculate total pills including stockAdjustment */
|
||||
export function getMedTotal(med: MedLike): number {
|
||||
if (med.packageType === "bottle") {
|
||||
if (isDiscreteCountPackageType(med.packageType)) {
|
||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
// Amount-based package types use the same canonical base field as the backend:
|
||||
// looseTablets stores the current amount baseline, while totalPills is kept in sync
|
||||
// for compatibility and UI helpers.
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
if (isPackageAmountPackageType(med.packageType)) {
|
||||
const baseStock = med.looseTablets ?? med.totalPills ?? 0;
|
||||
return baseStock + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
@@ -423,12 +427,12 @@ export function getMedTotal(med: MedLike): number {
|
||||
|
||||
/** Get the base package size (without stockAdjustment) */
|
||||
export function getPackageSize(med: MedLike): number {
|
||||
if (med.packageType === "bottle") {
|
||||
if (isDiscreteCountPackageType(med.packageType)) {
|
||||
return med.totalPills ?? med.looseTablets;
|
||||
}
|
||||
|
||||
// Amount-based package types reuse the backend canonical amount baseline.
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
if (isPackageAmountPackageType(med.packageType)) {
|
||||
return med.looseTablets ?? med.totalPills ?? 0;
|
||||
}
|
||||
// For blister type, calculate from packs + loose
|
||||
@@ -437,7 +441,7 @@ export function getPackageSize(med: MedLike): number {
|
||||
|
||||
/** Get the configured structural capacity used for stock display/limits. */
|
||||
export function getStockDisplayCapacity(med: MedLike): number {
|
||||
if (isLiquidContainerPackageType(med.packageType) || isTubePackageType(med.packageType)) {
|
||||
if (isPackageAmountPackageType(med.packageType)) {
|
||||
const packageCount = Math.max(1, med.packCount || 1);
|
||||
const packageAmountValue = Number(med.packageAmountValue ?? 0);
|
||||
if (Number.isFinite(packageAmountValue) && packageAmountValue > 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
|
||||
|
||||
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||
|
||||
@@ -6,7 +6,7 @@ export type PackageProfile = {
|
||||
value: PackageType;
|
||||
labelKey: string;
|
||||
amountBased: boolean;
|
||||
plannerUnitKind: "pills" | "ml" | "units";
|
||||
plannerUnitKind: "pills" | "ml" | "units" | "puffs" | "injections";
|
||||
allowsPillFormSelection: boolean;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,20 @@ export const PACKAGE_PROFILES: PackageProfile[] = [
|
||||
plannerUnitKind: "ml",
|
||||
allowsPillFormSelection: false,
|
||||
},
|
||||
{
|
||||
value: "inhaler",
|
||||
labelKey: "form.packageTypeInhaler",
|
||||
amountBased: true,
|
||||
plannerUnitKind: "puffs",
|
||||
allowsPillFormSelection: false,
|
||||
},
|
||||
{
|
||||
value: "injection",
|
||||
labelKey: "form.packageTypeInjection",
|
||||
amountBased: true,
|
||||
plannerUnitKind: "injections",
|
||||
allowsPillFormSelection: false,
|
||||
},
|
||||
];
|
||||
|
||||
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
|
||||
@@ -63,6 +77,16 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
|
||||
return normalizePackageType(packageType) === "liquid_container";
|
||||
}
|
||||
|
||||
export function isPackageAmountPackageType(packageType?: string | null): boolean {
|
||||
const normalized = normalizePackageType(packageType);
|
||||
return normalized === "tube" || normalized === "liquid_container";
|
||||
}
|
||||
|
||||
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
|
||||
const normalized = normalizePackageType(packageType);
|
||||
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
|
||||
}
|
||||
|
||||
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||
return getPackageProfile(packageType).amountBased;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user