fix: align frontend tube/liquid container semantics (#364)
* fix: align frontend tube/liquid container semantics * test(frontend): fix PR #364 CI regressions
This commit is contained in:
@@ -27,6 +27,12 @@ export function useEscapeKey(active: boolean, onClose: () => void, options?: { c
|
||||
if (!active) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && activeRef.current) {
|
||||
if (capture) {
|
||||
// In nested modals, consume Escape so parent/global handlers
|
||||
// do not process the same key press again.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -230,6 +230,31 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||
const isTubeOrLiquidPackage = med.packageType === "tube" || med.packageType === "liquid_container";
|
||||
let normalizedPackCount = String(med.packCount);
|
||||
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
|
||||
|
||||
if (isTubeOrLiquidPackage) {
|
||||
const safePackCount = med.packageType === "tube" ? 1 : Math.max(1, med.packCount || 1);
|
||||
normalizedPackCount = String(safePackCount);
|
||||
|
||||
const rawPackageAmount = Number(med.packageAmountValue ?? 0);
|
||||
const legacyKnownAmount = Math.max(0, Number(med.totalPills ?? 0), Number(med.looseTablets ?? 0));
|
||||
|
||||
if (med.packageType === "tube") {
|
||||
normalizedPackageAmountValue = String(
|
||||
legacyKnownAmount > 0 ? legacyKnownAmount : Math.max(1, rawPackageAmount)
|
||||
);
|
||||
} else if (rawPackageAmount > 0) {
|
||||
normalizedPackageAmountValue = String(rawPackageAmount);
|
||||
} else {
|
||||
normalizedPackageAmountValue = String(legacyKnownAmount);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedDerivedTotal = isTubeOrLiquidPackage
|
||||
? Math.max(0, (Number(normalizedPackCount) || 0) * (Number(normalizedPackageAmountValue) || 0))
|
||||
: null;
|
||||
|
||||
const bottleTotalPills =
|
||||
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
||||
@@ -253,6 +278,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
} else if (med.packageType === "liquid_container") {
|
||||
normalizedPackageAmountUnit = "ml";
|
||||
}
|
||||
let resolvedTotalPills = bottleTotalPills;
|
||||
if (normalizedDerivedTotal != null) {
|
||||
resolvedTotalPills = String(normalizedDerivedTotal);
|
||||
} else if (med.totalPills) {
|
||||
resolvedTotalPills = String(med.totalPills);
|
||||
}
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
@@ -261,13 +292,13 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
pillForm: resolvedPillForm,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: med.packageType ?? "blister",
|
||||
packCount: String(med.packCount),
|
||||
packCount: normalizedPackCount,
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
packageAmountValue: String(med.packageAmountValue ?? 0),
|
||||
packageAmountValue: normalizedPackageAmountValue,
|
||||
packageAmountUnit: normalizedPackageAmountUnit,
|
||||
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
||||
looseTablets: String(med.looseTablets),
|
||||
totalPills: resolvedTotalPills,
|
||||
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
@@ -317,11 +348,15 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
|
||||
if (key === "packageType") {
|
||||
if (value === "tube") {
|
||||
next.packCount = "1";
|
||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||
next.medicationForm = "topical";
|
||||
next.lifecycleCategory = "treatment_period";
|
||||
next.doseUnit = "units";
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (value === "liquid_container") {
|
||||
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
|
||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||
next.medicationForm = "liquid";
|
||||
next.lifecycleCategory = "refill_when_empty";
|
||||
next.doseUnit = "ml";
|
||||
@@ -349,6 +384,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
}
|
||||
|
||||
if (next.packageType === "tube") {
|
||||
next.packCount = "1";
|
||||
next.packageAmountUnit = "g";
|
||||
} else if (next.packageType === "liquid_container") {
|
||||
next.packageAmountUnit = "ml";
|
||||
|
||||
@@ -137,51 +137,97 @@ export function useRefill(): UseRefillReturn {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
const isTubePackage = selectedMed.packageType === "tube";
|
||||
const isBottlePackage = selectedMed.packageType === "bottle";
|
||||
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
||||
const isAmountPackage = isBottlePackage || isTubePackage || isLiquidPackage;
|
||||
const liquidAmountPerBottle = Math.max(
|
||||
1,
|
||||
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
|
||||
? Number(selectedMed.packageAmountValue)
|
||||
: Math.max(
|
||||
1,
|
||||
Math.round(Number(getPackageSize(selectedMed) || 0) / Math.max(1, Number(selectedMed.packCount || 1)))
|
||||
)
|
||||
);
|
||||
|
||||
// Clamp all fields to non-negative values.
|
||||
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
||||
let finalPartialPills =
|
||||
selectedMed.packageType === "bottle"
|
||||
? Math.max(0, editStockPartialBlisterPills)
|
||||
: Math.max(0, editStockPartialBlisterPills);
|
||||
let finalPartialPills = isAmountPackage
|
||||
? Math.max(0, editStockPartialBlisterPills)
|
||||
: Math.max(0, editStockPartialBlisterPills);
|
||||
const finalLoosePills = Math.max(0, editStockLoosePills);
|
||||
|
||||
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
||||
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
|
||||
if (!isAmountPackage && selectedMed.pillsPerBlister > 0) {
|
||||
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
||||
finalPartialPills %= selectedMed.pillsPerBlister;
|
||||
}
|
||||
|
||||
// Structural max = sealed package capacity only (no looseTablets offset).
|
||||
const structuralMax =
|
||||
selectedMed.packageType === "bottle"
|
||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||
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 liquidStructuralMax = isLiquidPackage
|
||||
? correctedLiquidBottleCount * liquidAmountPerBottle
|
||||
: structuralMax;
|
||||
|
||||
// For blister meds, only sealed pills are capped to package size.
|
||||
// Loose pills are extra and can be above package size.
|
||||
const desiredTotal =
|
||||
selectedMed.packageType === "bottle"
|
||||
? Math.min(structuralMax, Math.max(0, finalPartialPills))
|
||||
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||
finalLoosePills;
|
||||
let desiredTotal: number;
|
||||
if (isTubePackage) {
|
||||
desiredTotal = Math.max(0, finalPartialPills);
|
||||
} else if (isAmountPackage) {
|
||||
desiredTotal = Math.min(liquidStructuralMax, Math.max(0, finalPartialPills));
|
||||
} else {
|
||||
desiredTotal =
|
||||
Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||
finalLoosePills;
|
||||
}
|
||||
|
||||
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
||||
// - Bottle: looseTablets is the base (not changed during correction)
|
||||
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
||||
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
||||
const baseTotal =
|
||||
selectedMed.packageType === "bottle"
|
||||
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
|
||||
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||
let baseTotal: number;
|
||||
if (isLiquidPackage) {
|
||||
baseTotal = liquidStructuralMax;
|
||||
} else if (isAmountPackage) {
|
||||
baseTotal = getPackageSize(selectedMed); // bottle: stockAdjustment relative to fixed looseTablets base
|
||||
} else {
|
||||
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||
}
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// For blister corrections also send the new looseTablets value so the DB
|
||||
// reflects the actual loose count (avoids stale-split display on reload).
|
||||
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
|
||||
const patchBody: {
|
||||
stockAdjustment: number;
|
||||
looseTablets?: number;
|
||||
totalPills?: number;
|
||||
packageAmountValue?: number;
|
||||
packCount?: number;
|
||||
} = {
|
||||
stockAdjustment: newStockAdjustment,
|
||||
};
|
||||
if (selectedMed.packageType !== "bottle") {
|
||||
if (isTubePackage) {
|
||||
// Tube has fixed count=1 and no automatic depletion.
|
||||
// Correction must update the base amount fields directly.
|
||||
patchBody.stockAdjustment = 0;
|
||||
patchBody.packCount = 1;
|
||||
patchBody.totalPills = desiredTotal;
|
||||
patchBody.looseTablets = desiredTotal;
|
||||
patchBody.packageAmountValue = desiredTotal;
|
||||
} else if (isLiquidPackage) {
|
||||
// Liquid correction supports bottle-count updates.
|
||||
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
|
||||
patchBody.packCount = correctedLiquidBottleCount;
|
||||
patchBody.totalPills = liquidStructuralMax;
|
||||
} else if (!isAmountPackage) {
|
||||
patchBody.looseTablets = finalLoosePills;
|
||||
}
|
||||
|
||||
@@ -222,6 +268,10 @@ export function useRefill(): UseRefillReturn {
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockMedication(selectedMed);
|
||||
const isAmountPackage =
|
||||
selectedMed.packageType === "bottle" ||
|
||||
selectedMed.packageType === "tube" ||
|
||||
selectedMed.packageType === "liquid_container";
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
@@ -231,15 +281,20 @@ export function useRefill(): UseRefillReturn {
|
||||
// For blister, keep loose pills separated from sealed blister/partial counts.
|
||||
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
||||
const sealedPills = Math.max(0, currentStock - knownLoose);
|
||||
const fullBlisters =
|
||||
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
||||
const partialPills =
|
||||
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
||||
let fullBlisters: number;
|
||||
if (selectedMed.packageType === "liquid_container") {
|
||||
fullBlisters = Math.max(1, selectedMed.packCount);
|
||||
} else if (isAmountPackage) {
|
||||
fullBlisters = 0;
|
||||
} else {
|
||||
fullBlisters = Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
||||
}
|
||||
const partialPills = isAmountPackage ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
||||
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
|
||||
setEditStockLoosePills(isAmountPackage ? 0 : knownLoose);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: "editStock" }, "");
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user