@@ -20,6 +20,7 @@ import {
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getPackageSize,
|
||||
getStockDisplayCapacity,
|
||||
type IntakeUnit,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
@@ -213,9 +214,10 @@ export function MedDetailModal({
|
||||
|
||||
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
||||
const packageSize = getPackageSize(selectedMed);
|
||||
const stockDisplayCapacity = getStockDisplayCapacity(selectedMed);
|
||||
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
||||
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
|
||||
? (selectedMed.totalPills ?? packageSize)
|
||||
? stockDisplayCapacity
|
||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
@@ -226,7 +228,7 @@ export function MedDetailModal({
|
||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
|
||||
? (selectedMed.totalPills ?? packageSize)
|
||||
? stockDisplayCapacity
|
||||
: Math.max(0, structuralMax);
|
||||
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
||||
const amountPerPackage = (() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import type { Medication } from "../types";
|
||||
import {
|
||||
getMedDisplayName,
|
||||
getPackageSize,
|
||||
getMedTotal,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
@@ -313,9 +313,9 @@ function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
||||
|
||||
function getCurrentStockText(med: Medication, t: TFn): string {
|
||||
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
||||
return `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
|
||||
}
|
||||
return `${getPackageSize(med)} ${t("common.pills")}`;
|
||||
return `${getMedTotal(med)} ${t("common.pills")}`;
|
||||
}
|
||||
|
||||
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, getStockDisplayCapacity } from "../types";
|
||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
@@ -99,7 +99,7 @@ export function UserFilterModal({
|
||||
const status = medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||
const packageSize = getPackageSize(med);
|
||||
const packageSize = getStockDisplayCapacity(med);
|
||||
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
||||
|
||||
// Get intakes relevant to this person
|
||||
|
||||
@@ -70,12 +70,16 @@ export function useRefill(): UseRefillReturn {
|
||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
||||
|
||||
const clearRefillState = useCallback(() => {
|
||||
setShowRefillModal(false);
|
||||
const resetRefillForm = useCallback(() => {
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
setUsePrescriptionRefill(false);
|
||||
setRefillSaving(false);
|
||||
}, []);
|
||||
|
||||
const clearRefillState = useCallback(() => {
|
||||
setShowRefillModal(false);
|
||||
resetRefillForm();
|
||||
setRefillHistory([]);
|
||||
setRefillHistoryExpanded(false);
|
||||
setShowEditStockModal(false);
|
||||
@@ -84,7 +88,7 @@ export function useRefill(): UseRefillReturn {
|
||||
setEditStockLoosePills(0);
|
||||
setEditStockSaving(false);
|
||||
setEditStockMedication(null);
|
||||
}, []);
|
||||
}, [resetRefillForm]);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
@@ -190,9 +194,11 @@ export function useRefill(): UseRefillReturn {
|
||||
const structuralMax = isAmountPackage
|
||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||
const correctedLiquidBottleCount = isLiquidPackage
|
||||
? Math.max(1, finalFullBlisters)
|
||||
: Math.max(1, selectedMed.packCount);
|
||||
const isZeroReset = finalFullBlisters === 0 && finalPartialPills === 0 && finalLoosePills === 0;
|
||||
let correctedLiquidBottleCount = Math.max(0, selectedMed.packCount);
|
||||
if (isLiquidPackage) {
|
||||
correctedLiquidBottleCount = isZeroReset ? 0 : Math.max(1, finalFullBlisters);
|
||||
}
|
||||
const liquidStructuralMax = isLiquidPackage
|
||||
? correctedLiquidBottleCount * liquidAmountPerBottle
|
||||
: structuralMax;
|
||||
@@ -217,8 +223,10 @@ export function useRefill(): UseRefillReturn {
|
||||
let baseTotal: number;
|
||||
if (isLiquidPackage) {
|
||||
baseTotal = liquidStructuralMax;
|
||||
} else if (selectedMed.packageType === "bottle") {
|
||||
baseTotal = selectedMed.looseTablets;
|
||||
} else if (isAmountPackage) {
|
||||
baseTotal = getPackageSize(selectedMed); // bottle: stockAdjustment relative to fixed looseTablets base
|
||||
baseTotal = getPackageSize(selectedMed);
|
||||
} else {
|
||||
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||
}
|
||||
@@ -236,7 +244,17 @@ export function useRefill(): UseRefillReturn {
|
||||
} = {
|
||||
stockAdjustment: newStockAdjustment,
|
||||
};
|
||||
if (isTubePackage) {
|
||||
if (isZeroReset) {
|
||||
patchBody.stockAdjustment = 0;
|
||||
patchBody.packCount = 0;
|
||||
patchBody.looseTablets = 0;
|
||||
if (selectedMed.packageType === "bottle" || isAmountPackage) {
|
||||
patchBody.totalPills = 0;
|
||||
}
|
||||
if (isTubePackage || isLiquidPackage) {
|
||||
patchBody.packageAmountValue = 0;
|
||||
}
|
||||
} else if (isTubePackage) {
|
||||
// Tube has fixed count=1 and no automatic depletion.
|
||||
// Correction must update the base amount fields directly.
|
||||
patchBody.stockAdjustment = 0;
|
||||
@@ -277,9 +295,10 @@ export function useRefill(): UseRefillReturn {
|
||||
);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
resetRefillForm();
|
||||
setShowRefillModal(true);
|
||||
window.history.pushState({ modal: "refill" }, "");
|
||||
}, []);
|
||||
}, [resetRefillForm]);
|
||||
|
||||
const closeRefillModal = useCallback(() => {
|
||||
if (showRefillModal) {
|
||||
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
DOSE_UNITS,
|
||||
FIELD_LIMITS,
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getPackageProfile,
|
||||
getPackageSize,
|
||||
getStockDisplayCapacity,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
@@ -1392,23 +1394,35 @@ export function MedicationsPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="med-total">
|
||||
{t("medications.details.stock")}:{" "}
|
||||
{coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)}{" "}
|
||||
/ {getPackageSize(med)}
|
||||
{(() => {
|
||||
const stockDisplayCapacity = getStockDisplayCapacity(med);
|
||||
const currentStock = coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getMedTotal(med);
|
||||
|
||||
return (
|
||||
<>
|
||||
{t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{getMedicationStockSuffix(med)}
|
||||
{(coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)) > getPackageSize(med) && (
|
||||
<span
|
||||
className="info-tooltip tooltip-align-left warning-text"
|
||||
data-tooltip={t("tooltips.stockExceedsCapacity")}
|
||||
>
|
||||
{" "}
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const stockDisplayCapacity = getStockDisplayCapacity(med);
|
||||
const currentStock = coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getMedTotal(med);
|
||||
|
||||
return currentStock > stockDisplayCapacity ? (
|
||||
<span
|
||||
className="info-tooltip tooltip-align-left warning-text"
|
||||
data-tooltip={t("tooltips.stockExceedsCapacity")}
|
||||
>
|
||||
{" "}
|
||||
⚠️
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -921,6 +921,39 @@ describe("MedDetailModal stock overflow warning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal amount-based stock display", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows current liquid stock against configured structural capacity", () => {
|
||||
const liquidMed: Medication = {
|
||||
...mockMedication,
|
||||
id: 20,
|
||||
name: "Liquid Multi",
|
||||
packageType: "liquid_container",
|
||||
packCount: 4,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml",
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
};
|
||||
const liquidCoverage: Coverage = {
|
||||
name: "Liquid Multi",
|
||||
medsLeft: 450,
|
||||
daysLeft: 45,
|
||||
depletionDate: "2024-04-01",
|
||||
depletionTime: Date.now() + 45 * 86400000,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} coverage={{ all: [liquidCoverage] }} />);
|
||||
|
||||
expect(screen.getByText("450 / 600 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||
expect(screen.queryByText("450 / 450 form.packageAmountUnitMl")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal bottle package type", () => {
|
||||
const bottleMed: Medication = {
|
||||
id: 2,
|
||||
|
||||
@@ -113,6 +113,56 @@ describe("ReportModal", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exports bottle current stock separately from configured capacity", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: null,
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
packageType: "bottle",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
stockAdjustment: 50,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
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.docTotalCapacity: 100");
|
||||
expect(content).toContain("report.docCurrentStock: 70 common.pills");
|
||||
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates printable report when PDF format is selected", async () => {
|
||||
const onClose = vi.fn();
|
||||
const mockWrite = vi.fn();
|
||||
|
||||
@@ -344,6 +344,58 @@ describe("UserFilterModal", () => {
|
||||
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows liquid stock against configured multi-container capacity", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
const liquidMedication: Medication = {
|
||||
...mockMedication,
|
||||
id: 13,
|
||||
name: "Liquid Multi",
|
||||
genericName: "Liquid Generic",
|
||||
packageType: "liquid_container",
|
||||
packCount: 4,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml",
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
intakes: [
|
||||
{
|
||||
usage: 2,
|
||||
every: 1,
|
||||
start: "2024-01-01T09:32:00",
|
||||
intakeUnit: "ml",
|
||||
takenBy: "John",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const liquidCoverage: Coverage = {
|
||||
name: "Liquid Multi",
|
||||
medsLeft: 450,
|
||||
daysLeft: 30,
|
||||
depletionDate: null,
|
||||
depletionTime: null,
|
||||
nextDose: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<UserFilterModal
|
||||
selectedUser="John"
|
||||
meds={[liquidMedication]}
|
||||
coverage={{ all: [liquidCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("450/600 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||
expect(screen.queryByText("450/450 form.packageAmountUnitMl")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders liquid container intakes and stock in ml", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
@@ -89,6 +89,25 @@ describe("useRefill", () => {
|
||||
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
|
||||
});
|
||||
|
||||
it("resets stale refill form state when opening modal", () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.setRefillPacks(4);
|
||||
result.current.setRefillLoose(9);
|
||||
result.current.setUsePrescriptionRefill(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.openRefillModal();
|
||||
});
|
||||
|
||||
expect(result.current.showRefillModal).toBe(true);
|
||||
expect(result.current.refillPacks).toBe(1);
|
||||
expect(result.current.refillLoose).toBe(0);
|
||||
expect(result.current.usePrescriptionRefill).toBe(false);
|
||||
});
|
||||
|
||||
it("closes refill modal using history back", () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
@@ -325,42 +344,197 @@ describe("useRefill", () => {
|
||||
expect(mockLoadMeds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stock correction uses correct base for bottle type medications", async () => {
|
||||
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
|
||||
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
|
||||
// This mismatch caused the correction to compute the wrong stockAdjustment.
|
||||
it("resets blister stock correction payload to zero base fields", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const bottleMed: Medication = {
|
||||
id: 4,
|
||||
name: "Pills in a Box",
|
||||
packageType: "bottle",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
looseTablets: 150,
|
||||
stockAdjustment: -2,
|
||||
const blisterMed: Medication = {
|
||||
id: 8,
|
||||
name: "Zero Reset Blister",
|
||||
packageType: "blister",
|
||||
packCount: 2,
|
||||
blistersPerPack: 3,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
stockAdjustment: -4,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
|
||||
// getPackageSize for bottle = looseTablets = 150
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(blisterMed, {
|
||||
all: [{ name: "Zero Reset Blister", medsLeft: 31, daysLeft: 31 }] as Coverage[],
|
||||
});
|
||||
result.current.setEditStockFullBlisters(0);
|
||||
result.current.setEditStockPartialBlisterPills(0);
|
||||
result.current.setEditStockLoosePills(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
|
||||
});
|
||||
|
||||
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body).toEqual({
|
||||
stockAdjustment: 0,
|
||||
packCount: 0,
|
||||
looseTablets: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets bottle stock correction payload to zero base fields", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const bottleMed: Medication = {
|
||||
id: 9,
|
||||
name: "Zero Reset Bottle",
|
||||
packageType: "bottle",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
stockAdjustment: 5,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
// Pre-fill for bottle: full=0, partial=current total
|
||||
act(() => {
|
||||
result.current.openEditStockModal(bottleMed, {
|
||||
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
|
||||
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||
});
|
||||
result.current.setEditStockFullBlisters(0);
|
||||
result.current.setEditStockPartialBlisterPills(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
|
||||
});
|
||||
|
||||
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body).toEqual({
|
||||
stockAdjustment: 0,
|
||||
packCount: 0,
|
||||
looseTablets: 0,
|
||||
totalPills: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "liquid container",
|
||||
id: 10,
|
||||
med: {
|
||||
id: 10,
|
||||
name: "Zero Reset Liquid",
|
||||
medicationForm: "liquid",
|
||||
packageType: "liquid_container",
|
||||
doseUnit: "ml",
|
||||
packCount: 1,
|
||||
packageAmountValue: 180,
|
||||
packageAmountUnit: "ml",
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 180,
|
||||
looseTablets: 180,
|
||||
stockAdjustment: 0,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
} satisfies Medication,
|
||||
coverage: 180,
|
||||
},
|
||||
{
|
||||
label: "tube",
|
||||
id: 11,
|
||||
med: {
|
||||
id: 11,
|
||||
name: "Zero Reset Tube",
|
||||
medicationForm: "topical",
|
||||
packageType: "tube",
|
||||
doseUnit: "units",
|
||||
packCount: 2,
|
||||
packageAmountValue: 40,
|
||||
packageAmountUnit: "g",
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 80,
|
||||
looseTablets: 80,
|
||||
stockAdjustment: 0,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 2, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
} satisfies Medication,
|
||||
coverage: 80,
|
||||
},
|
||||
])("resets $label stock correction payload to zero amount-base fields", async ({ id, med, coverage }) => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(med, {
|
||||
all: [{ name: med.name, medsLeft: coverage, daysLeft: coverage }] as Coverage[],
|
||||
});
|
||||
result.current.setEditStockFullBlisters(0);
|
||||
result.current.setEditStockPartialBlisterPills(0);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitStockCorrection(id, med, mockLoadMeds);
|
||||
});
|
||||
|
||||
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body).toEqual({
|
||||
stockAdjustment: 0,
|
||||
packCount: 0,
|
||||
looseTablets: 0,
|
||||
totalPills: 0,
|
||||
packageAmountValue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const bottleMed: Medication = {
|
||||
id: 4,
|
||||
name: "Capacity Bottle",
|
||||
packageType: "bottle",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
stockAdjustment: 5,
|
||||
takenBy: [],
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
const mockLoadMeds = vi.fn();
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
act(() => {
|
||||
result.current.openEditStockModal(bottleMed, {
|
||||
all: [{ name: "Capacity Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||
});
|
||||
});
|
||||
|
||||
// User sets total to 149 pills.
|
||||
// User corrects current stock to 70 pills.
|
||||
act(() => {
|
||||
result.current.setEditStockPartialBlisterPills(149);
|
||||
result.current.setEditStockPartialBlisterPills(70);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -376,7 +550,8 @@ describe("useRefill", () => {
|
||||
);
|
||||
expect(fetchCall).toBeDefined();
|
||||
const body = JSON.parse(fetchCall![1].body as string);
|
||||
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
|
||||
expect(body.stockAdjustment).toBe(50);
|
||||
expect(body.looseTablets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stock correction clamps blister totals to package size", async () => {
|
||||
|
||||
@@ -646,4 +646,58 @@ describe("MedicationsPage form interactions", () => {
|
||||
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows liquid stock against configured multi-container capacity in the list", () => {
|
||||
const liquidMed = {
|
||||
...mockMeds[0],
|
||||
id: 2,
|
||||
name: "Liquid Multi",
|
||||
genericName: "Liquid Generic",
|
||||
packageType: "liquid_container" as const,
|
||||
packCount: 4,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml" as const,
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
};
|
||||
mockContextValue = createMockContext({
|
||||
meds: [liquidMed],
|
||||
coverageByMed: {
|
||||
"Liquid Multi": { medsLeft: 450 },
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText(/medications\.details\.stock: 450 \/ 600 ml/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/medications\.details\.stock: 450 \/ 450 ml/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows bottle current stock against configured bottle capacity in the list", () => {
|
||||
const bottleMed = {
|
||||
...mockMeds[0],
|
||||
id: 3,
|
||||
name: "Bottle Capacity",
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
stockAdjustment: 50,
|
||||
};
|
||||
mockContextValue = createMockContext({
|
||||
meds: [bottleMed],
|
||||
coverageByMed: {
|
||||
"Bottle Capacity": { medsLeft: 70 },
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText(/medications\.details\.stock: 70 \/ 100 common\.pills/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/medications\.details\.stock: 100 \/ 100 common\.pills/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
||||
import { FIELD_LIMITS, getMedTotal, getPackageSize, getStockDisplayCapacity } from "../types";
|
||||
|
||||
describe("getMedTotal", () => {
|
||||
it("calculates total pills without stock adjustment", () => {
|
||||
@@ -85,6 +85,20 @@ describe("getMedTotal", () => {
|
||||
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
|
||||
});
|
||||
|
||||
it("uses loose stock for bottle current total even when explicit capacity exists", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 20,
|
||||
stockAdjustment: 50,
|
||||
};
|
||||
|
||||
expect(getMedTotal(med)).toBe(70);
|
||||
});
|
||||
|
||||
it("ignores blister fields for bottle type", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
@@ -158,6 +172,20 @@ describe("getPackageSize", () => {
|
||||
expect(getPackageSize(med)).toBe(200);
|
||||
});
|
||||
|
||||
it("returns explicit bottle capacity instead of current stock", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 70,
|
||||
stockAdjustment: 25,
|
||||
};
|
||||
|
||||
expect(getPackageSize(med)).toBe(100);
|
||||
});
|
||||
|
||||
it("ignores blister fields for bottle type", () => {
|
||||
const med = {
|
||||
packageType: "bottle" as const,
|
||||
@@ -195,6 +223,62 @@ describe("getPackageSize", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStockDisplayCapacity", () => {
|
||||
it("returns configured multi-container capacity for liquid containers", () => {
|
||||
const liquid = {
|
||||
packageType: "liquid_container" as const,
|
||||
packCount: 4,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
packageAmountValue: 150,
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
};
|
||||
|
||||
expect(getStockDisplayCapacity(liquid)).toBe(600);
|
||||
});
|
||||
|
||||
it("returns configured multi-container capacity for tubes", () => {
|
||||
const tube = {
|
||||
packageType: "tube" as const,
|
||||
packCount: 4,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
packageAmountValue: 150,
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
};
|
||||
|
||||
expect(getStockDisplayCapacity(tube)).toBe(600);
|
||||
});
|
||||
|
||||
it("falls back to current package size when amount metadata is missing", () => {
|
||||
const liquid = {
|
||||
packageType: "liquid_container" as const,
|
||||
packCount: 4,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
};
|
||||
|
||||
expect(getStockDisplayCapacity(liquid)).toBe(450);
|
||||
});
|
||||
|
||||
it("keeps bottle semantics unchanged", () => {
|
||||
const bottle = {
|
||||
packageType: "bottle" as const,
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 80,
|
||||
};
|
||||
|
||||
expect(getStockDisplayCapacity(bottle)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIELD_LIMITS", () => {
|
||||
it("has correct limits for name field", () => {
|
||||
expect(FIELD_LIMITS.name.min).toBe(0);
|
||||
|
||||
@@ -15,7 +15,7 @@ export {
|
||||
} from "./package-profiles";
|
||||
|
||||
import type { PackageType } from "./package-profiles";
|
||||
import { isAmountBasedPackageType } from "./package-profiles";
|
||||
import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "./package-profiles";
|
||||
|
||||
// Common medication dose units
|
||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||
@@ -379,7 +379,10 @@ export function getMedDisplayName(med: { name: string; genericName?: string | nu
|
||||
// Helper Functions for Medication Calculations
|
||||
// =============================================================================
|
||||
|
||||
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
||||
type MedLike = Pick<
|
||||
Medication,
|
||||
"packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets" | "packageAmountValue"
|
||||
> & {
|
||||
stockAdjustment?: number;
|
||||
packageType?: PackageType;
|
||||
totalPills?: number | null;
|
||||
@@ -387,6 +390,10 @@ type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlist
|
||||
|
||||
/** Calculate total pills including stockAdjustment */
|
||||
export function getMedTotal(med: MedLike): number {
|
||||
if (med.packageType === "bottle") {
|
||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
|
||||
// Amount-based package types store their current base stock directly
|
||||
// in totalPills (fallback looseTablets for legacy rows).
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
@@ -399,6 +406,10 @@ export function getMedTotal(med: MedLike): number {
|
||||
|
||||
/** Get the base package size (without stockAdjustment) */
|
||||
export function getPackageSize(med: MedLike): number {
|
||||
if (med.packageType === "bottle") {
|
||||
return med.totalPills ?? med.looseTablets;
|
||||
}
|
||||
|
||||
// Amount-based package types use totalPills as base capacity
|
||||
if (isAmountBasedPackageType(med.packageType)) {
|
||||
return med.totalPills ?? med.looseTablets;
|
||||
@@ -406,3 +417,16 @@ export function getPackageSize(med: MedLike): number {
|
||||
// For blister type, calculate from packs + loose
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
}
|
||||
|
||||
/** Get the configured structural capacity used for stock display/limits. */
|
||||
export function getStockDisplayCapacity(med: MedLike): number {
|
||||
if (isLiquidContainerPackageType(med.packageType) || isTubePackageType(med.packageType)) {
|
||||
const packageCount = Math.max(1, med.packCount || 1);
|
||||
const packageAmountValue = Number(med.packageAmountValue ?? 0);
|
||||
if (Number.isFinite(packageAmountValue) && packageAmountValue > 0) {
|
||||
return packageCount * packageAmountValue;
|
||||
}
|
||||
}
|
||||
|
||||
return getPackageSize(med);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user