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:
File diff suppressed because it is too large
Load Diff
+1270
-5
File diff suppressed because it is too large
Load Diff
@@ -150,8 +150,7 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Overview table has class .table.table-7
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
const overviewTable = page.locator(".table.table-7");
|
|
||||||
await expect(overviewTable).toBeVisible();
|
await expect(overviewTable).toBeVisible();
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ function AppContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
if (scheduleLightboxImage) {
|
if (scheduleLightboxImage) {
|
||||||
closeScheduleLightbox();
|
closeScheduleLightbox();
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ export function MedDetailModal({
|
|||||||
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
||||||
// Lightbox has its own useEscapeKey internally.
|
// Lightbox has its own useEscapeKey internally.
|
||||||
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
||||||
useEscapeKey(showEditStockModal, onCloseEditStockModal);
|
useEscapeKey(showEditStockModal, onCloseEditStockModal, { capture: true });
|
||||||
useEscapeKey(showRefillModal, onCloseRefillModal);
|
useEscapeKey(showRefillModal, onCloseRefillModal, { capture: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showEditStockModal) return;
|
if (showEditStockModal) return;
|
||||||
@@ -192,12 +192,12 @@ export function MedDetailModal({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!selectedMed) return null;
|
if (!selectedMed) return null;
|
||||||
const isTube = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
|
const isAmountPackage = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
|
||||||
const stockUnitLabel = isTube
|
const amountUnitLabel =
|
||||||
? selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
|
selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
|
||||||
? "ml"
|
? t("form.packageAmountUnitMl")
|
||||||
: t("form.blisters.applications")
|
: t("form.packageAmountUnitG");
|
||||||
: null;
|
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
|
||||||
|
|
||||||
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
||||||
const packageSize = getPackageSize(selectedMed);
|
const packageSize = getPackageSize(selectedMed);
|
||||||
@@ -222,6 +222,18 @@ export function MedDetailModal({
|
|||||||
selectedMed.packageType === "liquid_container"
|
selectedMed.packageType === "liquid_container"
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
: Math.max(0, structuralMax);
|
: Math.max(0, structuralMax);
|
||||||
|
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
||||||
|
const amountPerPackage = (() => {
|
||||||
|
const configured = Number(selectedMed.packageAmountValue ?? 0);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) return configured;
|
||||||
|
|
||||||
|
const totalAmount = Number(stockDisplayTotal ?? 0);
|
||||||
|
if (Number.isFinite(totalAmount) && totalAmount > 0) {
|
||||||
|
return Math.max(0, totalAmount / packageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
const maxPartialPills = Math.min(
|
const maxPartialPills = Math.min(
|
||||||
Math.max(0, selectedMed.pillsPerBlister),
|
Math.max(0, selectedMed.pillsPerBlister),
|
||||||
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
||||||
@@ -231,6 +243,35 @@ export function MedDetailModal({
|
|||||||
const closeLabel = t("common.close");
|
const closeLabel = t("common.close");
|
||||||
const decrementLabel = t("editStock.decreaseValue");
|
const decrementLabel = t("editStock.decreaseValue");
|
||||||
const incrementLabel = t("editStock.increaseValue");
|
const incrementLabel = t("editStock.increaseValue");
|
||||||
|
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||||
|
if (selectedMed.packageType === "liquid_container") {
|
||||||
|
if (intakeUnit === "tsp") {
|
||||||
|
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
if (intakeUnit === "tbsp") {
|
||||||
|
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (selectedMed.packageType === "tube") {
|
||||||
|
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
const scheduleIntakes =
|
||||||
|
selectedMed.intakes && selectedMed.intakes.length > 0
|
||||||
|
? selectedMed.intakes
|
||||||
|
: selectedMed.blisters.map((blister) => ({
|
||||||
|
usage: blister.usage,
|
||||||
|
every: blister.every,
|
||||||
|
start: blister.start,
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
||||||
|
intakeUnit: null,
|
||||||
|
}));
|
||||||
|
const hasAnyIntakeReminder = scheduleIntakes.some(
|
||||||
|
(intake) => (intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false) === true
|
||||||
|
);
|
||||||
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
||||||
let normalizedFull = Math.max(0, nextFull);
|
let normalizedFull = Math.max(0, nextFull);
|
||||||
let normalizedPartial = Math.max(0, nextPartial);
|
let normalizedPartial = Math.max(0, nextPartial);
|
||||||
@@ -359,6 +400,10 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
const renderEditStockModal = () => {
|
const renderEditStockModal = () => {
|
||||||
if (!showEditStockModal) return null;
|
if (!showEditStockModal) return null;
|
||||||
|
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
||||||
|
const liquidBottleCount = Math.max(1, editStockFullBlisters);
|
||||||
|
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
|
||||||
|
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
|
||||||
const fullInputMax = Math.min(
|
const fullInputMax = Math.min(
|
||||||
maxFullBlisters,
|
maxFullBlisters,
|
||||||
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
||||||
@@ -372,14 +417,14 @@ export function MedDetailModal({
|
|||||||
onCloseEditStockModal();
|
onCloseEditStockModal();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content edit-stock-modal"
|
className="modal-content edit-stock-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -404,11 +449,15 @@ export function MedDetailModal({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{(selectedMed.packageType === "bottle" ||
|
{selectedMed.packageType === "bottle" && (
|
||||||
selectedMed.packageType === "tube" ||
|
|
||||||
selectedMed.packageType === "liquid_container") && (
|
|
||||||
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
|
{(selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container") && (
|
||||||
|
<p className="edit-stock-cap-info">
|
||||||
|
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
|
||||||
|
{amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{showStockCapNotice && (
|
{showStockCapNotice && (
|
||||||
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
@@ -420,7 +469,9 @@ export function MedDetailModal({
|
|||||||
selectedMed.packageType === "bottle" ||
|
selectedMed.packageType === "bottle" ||
|
||||||
selectedMed.packageType === "tube" ||
|
selectedMed.packageType === "tube" ||
|
||||||
selectedMed.packageType === "liquid_container";
|
selectedMed.packageType === "liquid_container";
|
||||||
const enteredTotal = isBottle
|
const enteredTotal = isLiquidPackage
|
||||||
|
? Math.min(liquidCapacity, editStockPartialBlisterPills)
|
||||||
|
: isBottle
|
||||||
? editStockPartialBlisterPills
|
? editStockPartialBlisterPills
|
||||||
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
||||||
editStockPartialBlisterPills +
|
editStockPartialBlisterPills +
|
||||||
@@ -434,36 +485,39 @@ export function MedDetailModal({
|
|||||||
<div className="edit-stock-form">
|
<div className="edit-stock-form">
|
||||||
{isBottle ? (
|
{isBottle ? (
|
||||||
<label>
|
<label>
|
||||||
{t("editStock.totalPills")}
|
{isAmountPackage ? t("form.currentAmount") : t("editStock.totalPills")}
|
||||||
{renderStepperInput({
|
{renderStepperInput({
|
||||||
value: editStockPartialInput,
|
value: editStockPartialInput,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: structuralMax,
|
max: isLiquidPackage ? liquidCapacity : structuralMax,
|
||||||
onChange: (raw) => {
|
onChange: (raw) => {
|
||||||
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||||
setEditStockPartialInput(raw);
|
setEditStockPartialInput(raw);
|
||||||
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(structuralMax, parsed));
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
setShowStockCapNotice(parsed > structuralMax);
|
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(maxTotal, parsed));
|
||||||
|
setShowStockCapNotice(parsed > maxTotal);
|
||||||
},
|
},
|
||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
const normalized = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const normalized = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput)));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput))
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(normalized);
|
onEditStockPartialBlisterPillsChange(normalized);
|
||||||
setEditStockPartialInput(String(normalized));
|
setEditStockPartialInput(String(normalized));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
onStep: (delta) => {
|
onStep: (delta) => {
|
||||||
const next = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const next = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput) + delta));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput) + delta)
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(next);
|
onEditStockPartialBlisterPillsChange(next);
|
||||||
setEditStockPartialInput(String(next));
|
setEditStockPartialInput(String(next));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
|
||||||
|
{t("form.currentAmount")}: {Math.max(0, editStockPartialBlisterPills)} {amountUnitLabel} /{" "}
|
||||||
|
{liquidCapacity} {amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -601,6 +655,43 @@ export function MedDetailModal({
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<label>
|
||||||
|
{t("form.bottles")}
|
||||||
|
{renderStepperInput({
|
||||||
|
value: editStockFullInput,
|
||||||
|
min: 1,
|
||||||
|
max: Number.MAX_SAFE_INTEGER,
|
||||||
|
onChange: (raw) => {
|
||||||
|
const nextBottleCount = raw === "" ? 1 : Math.max(1, parseStockInput(raw));
|
||||||
|
setEditStockFullInput(raw === "" ? "1" : raw);
|
||||||
|
onEditStockFullBlistersChange(nextBottleCount);
|
||||||
|
const syncedTotal = Math.round(nextBottleCount * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
const normalized = Math.max(1, parseStockInput(editStockFullInput));
|
||||||
|
onEditStockFullBlistersChange(normalized);
|
||||||
|
setEditStockFullInput(String(normalized));
|
||||||
|
const syncedTotal = Math.round(normalized * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onStep: (delta) => {
|
||||||
|
const next = Math.max(1, parseStockInput(editStockFullInput) + delta);
|
||||||
|
onEditStockFullBlistersChange(next);
|
||||||
|
setEditStockFullInput(String(next));
|
||||||
|
const syncedTotal = Math.round(next * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-stock-summary">
|
<div className="edit-stock-summary">
|
||||||
@@ -608,14 +699,18 @@ export function MedDetailModal({
|
|||||||
<span>{t("editStock.currentTotal")}:</span>
|
<span>{t("editStock.currentTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{currentTotal}
|
{currentTotal}
|
||||||
{isTube ? ` ${stockUnitLabel}` : ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-row">
|
<div className="summary-row">
|
||||||
<span>{t("editStock.newTotal")}:</span>
|
<span>{t("editStock.newTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{newTotal}
|
{newTotal}
|
||||||
{isTube ? ` ${stockUnitLabel}` : ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`summary-row difference ${differenceClass}`}>
|
<div className={`summary-row difference ${differenceClass}`}>
|
||||||
@@ -623,7 +718,7 @@ export function MedDetailModal({
|
|||||||
<span>
|
<span>
|
||||||
{difference > 0 ? "+" : ""}
|
{difference > 0 ? "+" : ""}
|
||||||
{difference}
|
{difference}
|
||||||
{isTube
|
{isAmountPackage
|
||||||
? ` ${stockUnitLabel}`
|
? ` ${stockUnitLabel}`
|
||||||
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
|
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -738,9 +833,13 @@ export function MedDetailModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
||||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
<span className="med-detail-label">
|
||||||
|
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
|
||||||
|
</span>
|
||||||
<span className={`med-detail-value ${textClass}`}>
|
<span className={`med-detail-value ${textClass}`}>
|
||||||
{currentStock} / {stockDisplayTotal}
|
{isAmountPackage
|
||||||
|
? `${formatNumber(currentStock)} / ${formatNumber(stockDisplayTotal)} ${amountUnitLabel}`
|
||||||
|
: `${currentStock} / ${stockDisplayTotal}`}
|
||||||
{currentStock > stockDisplayTotal && (
|
{currentStock > stockDisplayTotal && (
|
||||||
<span
|
<span
|
||||||
className="info-tooltip tooltip-align-left warning-text"
|
className="info-tooltip tooltip-align-left warning-text"
|
||||||
@@ -767,6 +866,16 @@ export function MedDetailModal({
|
|||||||
? t("form.packageTypeLiquidContainer")
|
? t("form.packageTypeLiquidContainer")
|
||||||
: t("form.packageTypeBlister")}
|
: t("form.packageTypeBlister")}
|
||||||
)
|
)
|
||||||
|
{selectedMed.packageType === "tube" && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedMed.packageType === "liquid_container" && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{selectedMed.packageType === "blister" ? (
|
||||||
@@ -784,9 +893,47 @@ export function MedDetailModal({
|
|||||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : selectedMed.packageType === "liquid_container" ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.bottles")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerBottle")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : selectedMed.packageType === "tube" ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.tubes")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerTube")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{isTube ? t("form.totalAmount") : t("form.totalCapacity")}</span>
|
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||||
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
|
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -820,54 +967,33 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.intakeSchedule")}{" "}
|
{t("modal.intakeSchedule")}{" "}
|
||||||
{selectedMed.intakeRemindersEnabled && (
|
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && (
|
||||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||||
<Bell size={14} aria-hidden="true" />
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-schedules">
|
<div className="med-detail-schedules">
|
||||||
{(selectedMed.intakes && selectedMed.intakes.length > 0
|
{scheduleIntakes.map((intake, idx) => {
|
||||||
? selectedMed.intakes
|
|
||||||
: selectedMed.blisters.map((blister) => ({
|
|
||||||
usage: blister.usage,
|
|
||||||
every: blister.every,
|
|
||||||
start: blister.start,
|
|
||||||
takenBy: null,
|
|
||||||
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
|
||||||
}))
|
|
||||||
).map((intake, idx) => {
|
|
||||||
const hasPerIntakeTakenBy = !!intake.takenBy;
|
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||||
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||||
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||||
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
<div
|
||||||
|
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
|
||||||
|
className="med-schedule-row blister-row-simple"
|
||||||
|
>
|
||||||
<span className="med-schedule-usage">
|
<span className="med-schedule-usage">
|
||||||
{totalUsage}
|
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||||
{isTube ? ` ${stockUnitLabel}` : ` ${totalUsage !== 1 ? t("common.pills") : t("common.pill")}`}
|
|
||||||
{selectedMed.pillWeightMg &&
|
{selectedMed.pillWeightMg &&
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="med-schedule-freq">
|
<span className="med-schedule-freq">
|
||||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||||
</span>
|
</span>
|
||||||
{hasPerIntakeTakenBy && (
|
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
|
||||||
<span className="med-schedule-person">
|
|
||||||
{intake.takenBy}
|
|
||||||
{showIntakeBell && (
|
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={13} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!hasPerIntakeTakenBy && showIntakeBell && (
|
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={13} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="med-schedule-time">
|
<span className="med-schedule-time">
|
||||||
{t("modal.at")}{" "}
|
{t("modal.at")}{" "}
|
||||||
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
@@ -875,6 +1001,11 @@ export function MedDetailModal({
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
{showIntakeBell && (
|
||||||
|
<span className="med-schedule-bell" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<Bell size={12} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -991,7 +1122,7 @@ export function MedDetailModal({
|
|||||||
? entry.loosePillsAdded
|
? entry.loosePillsAdded
|
||||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||||
entry.loosePillsAdded;
|
entry.loosePillsAdded;
|
||||||
return `+${total}${isTube ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||||
})()}
|
})()}
|
||||||
{entry.usedPrescription && (
|
{entry.usedPrescription && (
|
||||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||||
@@ -1179,7 +1310,9 @@ export function MedDetailModal({
|
|||||||
return totalRefill > 0 ? (
|
return totalRefill > 0 ? (
|
||||||
<span className="refill-preview">
|
<span className="refill-preview">
|
||||||
+{totalRefill}
|
+{totalRefill}
|
||||||
{isTube ? ` ${stockUnitLabel}` : ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import { useScrollLock } from "../hooks/useScrollLock";
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
@@ -131,8 +131,11 @@ export function MobileEditModal({
|
|||||||
return form.pillForm === "tablet";
|
return form.pillForm === "tablet";
|
||||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||||
|
|
||||||
const usageLabel = useMemo(() => {
|
const getUsageLabel = useCallback(
|
||||||
|
(intake: (typeof form.intakes)[number]) => {
|
||||||
if (form.packageType === "liquid_container") {
|
if (form.packageType === "liquid_container") {
|
||||||
|
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
||||||
|
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
||||||
return t("form.blisters.usageMl");
|
return t("form.blisters.usageMl");
|
||||||
}
|
}
|
||||||
if (form.packageType === "tube") {
|
if (form.packageType === "tube") {
|
||||||
@@ -140,7 +143,9 @@ export function MobileEditModal({
|
|||||||
}
|
}
|
||||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||||
return t("form.blisters.usageTablets");
|
return t("form.blisters.usageTablets");
|
||||||
}, [form.packageType, form.medicationForm, form.pillForm, t]);
|
},
|
||||||
|
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||||
|
);
|
||||||
|
|
||||||
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
||||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||||
@@ -433,6 +438,14 @@ export function MobileEditModal({
|
|||||||
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.medicationEndDate")}
|
||||||
|
<DateInput
|
||||||
|
value={form.medicationEndDate}
|
||||||
|
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillForm")}
|
{t("form.pillForm")}
|
||||||
@@ -461,14 +474,6 @@ export function MobileEditModal({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="full">
|
|
||||||
{t("form.medicationEndDate")}
|
|
||||||
<DateInput
|
|
||||||
value={form.medicationEndDate}
|
|
||||||
onChange={(e) => onHandleValueChange("medicationEndDate", e.target.value)}
|
|
||||||
placeholder={t("common.optional")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{form.medicationEndDate && (
|
{form.medicationEndDate && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.autoMarkObsoleteAfterEndDate")}
|
{t("form.autoMarkObsoleteAfterEndDate")}
|
||||||
@@ -601,13 +606,7 @@ export function MobileEditModal({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.tubes")}
|
{t("form.tubes")}
|
||||||
<FormNumberStepper
|
<div className="static-value">1</div>
|
||||||
value={form.packCount}
|
|
||||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
|
||||||
min={1}
|
|
||||||
decrementLabel={decrementValueLabel}
|
|
||||||
incrementLabel={incrementValueLabel}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.packageAmountPerTube")}
|
{t("form.packageAmountPerTube")}
|
||||||
@@ -641,6 +640,51 @@ export function MobileEditModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.packageType === "liquid_container") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
{t("form.bottles")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.packCount}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageAmountPerBottle")}
|
||||||
|
<div className="dose-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={form.packageAmountValue ?? "0"}
|
||||||
|
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value="ml"
|
||||||
|
disabled
|
||||||
|
className="dose-unit-select"
|
||||||
|
aria-label={t("form.packageAmountUnitMl")}
|
||||||
|
>
|
||||||
|
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("form.totalAmount")}
|
||||||
|
<div className="static-value">
|
||||||
|
{(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)}{" "}
|
||||||
|
{t("form.packageAmountUnitMl")}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -666,7 +710,7 @@ export function MobileEditModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
|
{form.packageType === "bottle" && (
|
||||||
<div className="full stock-total-row">
|
<div className="full stock-total-row">
|
||||||
<div className="stock-total-field">
|
<div className="stock-total-field">
|
||||||
<p className="sub">
|
<p className="sub">
|
||||||
@@ -678,29 +722,6 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "liquid_container" && (
|
|
||||||
<label className="full">
|
|
||||||
{t("form.packageAmount")}
|
|
||||||
<div className="dose-input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={form.packageAmountValue ?? "0"}
|
|
||||||
onChange={(e) => onHandleValueChange("packageAmountValue", e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value="ml"
|
|
||||||
disabled
|
|
||||||
className="dose-unit-select"
|
|
||||||
aria-label={t("form.packageAmountUnitMl")}
|
|
||||||
>
|
|
||||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
@@ -778,11 +799,11 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
{form.intakes.map((intake, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div
|
<div
|
||||||
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
|
||||||
className="blister-row"
|
className="blister-row"
|
||||||
>
|
>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{usageLabel}</span>
|
<span>{getUsageLabel(intake)}</span>
|
||||||
<FormNumberStepper
|
<FormNumberStepper
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||||
|
|||||||
@@ -324,6 +324,13 @@ function getCurrentStockText(med: Medication, t: TFn): string {
|
|||||||
return `${getPackageSize(med)} ${t("common.pills")}`;
|
return `${getPackageSize(med)} ${t("common.pills")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||||
|
if (med.packageType === "bottle") return t("report.docBottle");
|
||||||
|
if (med.packageType === "tube") return t("report.docTube");
|
||||||
|
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
||||||
|
return t("report.docBlister");
|
||||||
|
}
|
||||||
|
|
||||||
function generateTextReport(
|
function generateTextReport(
|
||||||
meds: Medication[],
|
meds: Medication[],
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
@@ -366,18 +373,7 @@ function generateTextReport(
|
|||||||
|
|
||||||
// Package / Stock
|
// Package / Stock
|
||||||
lines.push(h3(t("report.docPackage")));
|
lines.push(h3(t("report.docPackage")));
|
||||||
lines.push(
|
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
|
||||||
item(
|
|
||||||
t("report.docPackageType"),
|
|
||||||
med.packageType === "bottle"
|
|
||||||
? t("report.docBottle")
|
|
||||||
: med.packageType === "tube"
|
|
||||||
? t("report.docTube")
|
|
||||||
: med.packageType === "liquid_container"
|
|
||||||
? t("form.packageTypeLiquidContainer")
|
|
||||||
: t("report.docBlister")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (med.packageType === "blister") {
|
if (med.packageType === "blister") {
|
||||||
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
||||||
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
||||||
@@ -575,15 +571,7 @@ function buildPrintHtml(
|
|||||||
// Package / Stock
|
// Package / Stock
|
||||||
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||||
s += `<table><tbody>`;
|
s += `<table><tbody>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
|
||||||
med.packageType === "bottle"
|
|
||||||
? t("report.docBottle")
|
|
||||||
: med.packageType === "tube"
|
|
||||||
? t("report.docTube")
|
|
||||||
: med.packageType === "liquid_container"
|
|
||||||
? t("form.packageTypeLiquidContainer")
|
|
||||||
: t("report.docBlister")
|
|
||||||
)}</td></tr>`;
|
|
||||||
if (med.packageType === "blister") {
|
if (med.packageType === "blister") {
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ import { MedicationAvatar } from "./MedicationAvatar";
|
|||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number }
|
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
|
||||||
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
|
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
return { className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
||||||
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
||||||
@@ -44,6 +53,100 @@ export function SharedSchedule() {
|
|||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
|
|
||||||
|
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
||||||
|
med?.packageType === "liquid_container";
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertUsageForStock = (
|
||||||
|
usage: number,
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
||||||
|
): number => {
|
||||||
|
if (med?.packageType === "tube") return 0;
|
||||||
|
if (!isLiquidContainerMed(med)) return usage;
|
||||||
|
return convertLiquidUsageToMl(usage, unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (value: number) => {
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
return String(rounded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
total: number,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getVisibleStockStatus = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
|
||||||
|
|
||||||
// Theme preference: light, dark, or system
|
// Theme preference: light, dark, or system
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
type ThemePreference = "light" | "dark" | "system";
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
||||||
@@ -309,6 +412,7 @@ export function SharedSchedule() {
|
|||||||
when: number;
|
when: number;
|
||||||
medName: string;
|
medName: string;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
timeStr: string;
|
timeStr: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
@@ -345,6 +449,7 @@ export function SharedSchedule() {
|
|||||||
when: t,
|
when: t,
|
||||||
medName: getMedDisplayName(med),
|
medName: getMedDisplayName(med),
|
||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
|
intakeUnit: intake.intakeUnit ?? null,
|
||||||
isPast,
|
isPast,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
||||||
@@ -431,7 +536,6 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
for (const med of data.medications) {
|
for (const med of data.medications) {
|
||||||
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
|
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
|
||||||
const blisters = med.blisters;
|
|
||||||
|
|
||||||
// Count unique people from all intakes (for per-intake takenBy)
|
// Count unique people from all intakes (for per-intake takenBy)
|
||||||
const uniquePeople = new Set<string>();
|
const uniquePeople = new Set<string>();
|
||||||
@@ -443,9 +547,9 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Calculate daily consumption rate accounting for per-intake takenBy
|
// Calculate daily consumption rate accounting for per-intake takenBy
|
||||||
let dailyRate = 0;
|
let dailyRate = 0;
|
||||||
blisters.forEach((s, idx) => {
|
intakes.forEach((intake) => {
|
||||||
const baseRate = s.every > 0 ? s.usage / s.every : 0;
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const intake = intakes[idx];
|
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||||
if (intake?.takenBy) {
|
if (intake?.takenBy) {
|
||||||
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
||||||
} else {
|
} else {
|
||||||
@@ -458,9 +562,10 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (calcMode === "automatic") {
|
if (calcMode === "automatic") {
|
||||||
// Time-based: every scheduled dose counts as consumed once its time has passed
|
// Time-based: every scheduled dose counts as consumed once its time has passed
|
||||||
blisters.forEach((s, blisterIdx) => {
|
intakes.forEach((intake, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const blisterStart = new Date(intake.start).getTime();
|
||||||
|
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||||
|
|
||||||
let effectiveStart: number;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
@@ -472,7 +577,6 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart)) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||||
@@ -482,7 +586,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (effectiveStart <= now) {
|
if (effectiveStart <= now) {
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
lastAutoConsumedDateMs = new Date(
|
lastAutoConsumedDateMs = new Date(
|
||||||
lastDoseTime.getFullYear(),
|
lastDoseTime.getFullYear(),
|
||||||
@@ -510,7 +614,7 @@ export function SharedSchedule() {
|
|||||||
const bIdx = parseInt(parts[1], 10);
|
const bIdx = parseInt(parts[1], 10);
|
||||||
const timestamp = parseInt(parts[2], 10);
|
const timestamp = parseInt(parts[2], 10);
|
||||||
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
earlyTakenConsumed += s.usage;
|
earlyTakenConsumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,8 +629,8 @@ export function SharedSchedule() {
|
|||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
const blisterIdx = parseInt(parts[1], 10);
|
||||||
const doseTimestamp = parseInt(parts[2], 10);
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
if (medId === med.id && blisters[blisterIdx]) {
|
if (medId === med.id && intakes[blisterIdx]) {
|
||||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
const blisterStartDate = new Date(intakes[blisterIdx].start);
|
||||||
const blisterStartDateOnly = new Date(
|
const blisterStartDateOnly = new Date(
|
||||||
blisterStartDate.getFullYear(),
|
blisterStartDate.getFullYear(),
|
||||||
blisterStartDate.getMonth(),
|
blisterStartDate.getMonth(),
|
||||||
@@ -534,7 +638,11 @@ export function SharedSchedule() {
|
|||||||
).getTime();
|
).getTime();
|
||||||
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
||||||
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += convertUsageForStock(
|
||||||
|
intakes[blisterIdx].usage,
|
||||||
|
med,
|
||||||
|
intakes[blisterIdx].intakeUnit ?? "ml"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,11 +677,13 @@ export function SharedSchedule() {
|
|||||||
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
||||||
const statuses = meds.map((item) => {
|
const statuses = meds.map((item) => {
|
||||||
const coverage = coverageByMed[item.medName];
|
const coverage = coverageByMed[item.medName];
|
||||||
|
const med = data?.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
||||||
if (!coverage) return "success";
|
if (!coverage) return "success";
|
||||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
const rawStatus = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds, med?.packageType);
|
||||||
return status.className;
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
|
return status?.className ?? "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
@@ -583,6 +693,11 @@ export function SharedSchedule() {
|
|||||||
const showStock = data?.shareStockStatus !== false;
|
const showStock = data?.shareStockStatus !== false;
|
||||||
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||||
|
|
||||||
|
const renderDoseUsage = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
|
||||||
|
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
|
||||||
|
|
||||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||||
function isDoseIdDone(doseId: string): boolean {
|
function isDoseIdDone(doseId: string): boolean {
|
||||||
if (takenDoses.has(doseId)) return true;
|
if (takenDoses.has(doseId)) return true;
|
||||||
@@ -809,9 +924,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -840,9 +961,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -853,9 +978,7 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -993,9 +1116,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1023,9 +1152,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1040,9 +1173,7 @@ export function SharedSchedule() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1169,9 +1300,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1199,9 +1336,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1212,9 +1353,7 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export function UserFilterModal({
|
|||||||
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
|
||||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||||
const status = medCoverage
|
const status = medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||||
: getStockStatus(null, getMedTotal(med), settings);
|
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||||
const packageSize = getPackageSize(med);
|
const packageSize = getPackageSize(med);
|
||||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, use
|
|||||||
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -17,6 +17,7 @@ export type DoseInfo = {
|
|||||||
timeStr: string;
|
timeStr: string;
|
||||||
when: number;
|
when: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string[];
|
takenBy: string[];
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -384,6 +385,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
||||||
const statuses = dayMeds.map((item) => {
|
const statuses = dayMeds.map((item) => {
|
||||||
const cov = coverageByMed[item.medName];
|
const cov = coverageByMed[item.medName];
|
||||||
|
const med = activeMeds.find((m) => m.name === item.medName || m.genericName === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
|
|
||||||
// Will be out of stock by this day?
|
// Will be out of stock by this day?
|
||||||
@@ -392,21 +394,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cov) return "success";
|
if (!cov) return "success";
|
||||||
const { daysLeft, medsLeft } = cov;
|
const status = getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
if (status.className === "danger") return "danger";
|
||||||
// Currently out of stock
|
if (status.className === "warning") return "warning";
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
|
||||||
// No schedule (can't calculate)
|
|
||||||
if (daysLeft === null) return "success";
|
|
||||||
// Low stock: < lowStockDays (warning)
|
|
||||||
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
|
|
||||||
// Normal/High stock
|
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
},
|
},
|
||||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
[coverageByMed, depletionByMed, activeMeds, stockThresholds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedSchedule = useMemo(() => {
|
const groupedSchedule = useMemo(() => {
|
||||||
@@ -439,6 +435,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
timeStr: event.timeStr,
|
timeStr: event.timeStr,
|
||||||
when: event.when,
|
when: event.when,
|
||||||
usage: event.usage,
|
usage: event.usage,
|
||||||
|
intakeUnit: event.intakeUnit ?? null,
|
||||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||||
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export function useEscapeKey(active: boolean, onClose: () => void, options?: { c
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && activeRef.current) {
|
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();
|
onCloseRef.current();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -230,6 +230,31 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
const authorizedRefills = Math.max(0, med.prescriptionAuthorizedRefills ?? 0);
|
||||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), 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 =
|
const bottleTotalPills =
|
||||||
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
||||||
@@ -253,6 +278,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
} else if (med.packageType === "liquid_container") {
|
} else if (med.packageType === "liquid_container") {
|
||||||
normalizedPackageAmountUnit = "ml";
|
normalizedPackageAmountUnit = "ml";
|
||||||
}
|
}
|
||||||
|
let resolvedTotalPills = bottleTotalPills;
|
||||||
|
if (normalizedDerivedTotal != null) {
|
||||||
|
resolvedTotalPills = String(normalizedDerivedTotal);
|
||||||
|
} else if (med.totalPills) {
|
||||||
|
resolvedTotalPills = String(med.totalPills);
|
||||||
|
}
|
||||||
const editForm: FormState = {
|
const editForm: FormState = {
|
||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName ?? "",
|
genericName: med.genericName ?? "",
|
||||||
@@ -261,13 +292,13 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
pillForm: resolvedPillForm,
|
pillForm: resolvedPillForm,
|
||||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: med.packageType ?? "blister",
|
||||||
packCount: String(med.packCount),
|
packCount: normalizedPackCount,
|
||||||
blistersPerPack: String(med.blistersPerPack),
|
blistersPerPack: String(med.blistersPerPack),
|
||||||
pillsPerBlister: String(med.pillsPerBlister),
|
pillsPerBlister: String(med.pillsPerBlister),
|
||||||
packageAmountValue: String(med.packageAmountValue ?? 0),
|
packageAmountValue: normalizedPackageAmountValue,
|
||||||
packageAmountUnit: normalizedPackageAmountUnit,
|
packageAmountUnit: normalizedPackageAmountUnit,
|
||||||
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
totalPills: resolvedTotalPills,
|
||||||
looseTablets: String(med.looseTablets),
|
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : String(med.looseTablets),
|
||||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
medicationStartDate: med.medicationStartDate ?? "",
|
medicationStartDate: med.medicationStartDate ?? "",
|
||||||
@@ -317,11 +348,15 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
|
|
||||||
if (key === "packageType") {
|
if (key === "packageType") {
|
||||||
if (value === "tube") {
|
if (value === "tube") {
|
||||||
|
next.packCount = "1";
|
||||||
|
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||||
next.medicationForm = "topical";
|
next.medicationForm = "topical";
|
||||||
next.lifecycleCategory = "treatment_period";
|
next.lifecycleCategory = "treatment_period";
|
||||||
next.doseUnit = "units";
|
next.doseUnit = "units";
|
||||||
next.packageAmountUnit = "g";
|
next.packageAmountUnit = "g";
|
||||||
} else if (value === "liquid_container") {
|
} 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.medicationForm = "liquid";
|
||||||
next.lifecycleCategory = "refill_when_empty";
|
next.lifecycleCategory = "refill_when_empty";
|
||||||
next.doseUnit = "ml";
|
next.doseUnit = "ml";
|
||||||
@@ -349,6 +384,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (next.packageType === "tube") {
|
if (next.packageType === "tube") {
|
||||||
|
next.packCount = "1";
|
||||||
next.packageAmountUnit = "g";
|
next.packageAmountUnit = "g";
|
||||||
} else if (next.packageType === "liquid_container") {
|
} else if (next.packageType === "liquid_container") {
|
||||||
next.packageAmountUnit = "ml";
|
next.packageAmountUnit = "ml";
|
||||||
|
|||||||
@@ -137,51 +137,97 @@ export function useRefill(): UseRefillReturn {
|
|||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockSaving(true);
|
setEditStockSaving(true);
|
||||||
try {
|
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.
|
// Clamp all fields to non-negative values.
|
||||||
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
||||||
let finalPartialPills =
|
let finalPartialPills = isAmountPackage
|
||||||
selectedMed.packageType === "bottle"
|
|
||||||
? Math.max(0, editStockPartialBlisterPills)
|
? Math.max(0, editStockPartialBlisterPills)
|
||||||
: Math.max(0, editStockPartialBlisterPills);
|
: Math.max(0, editStockPartialBlisterPills);
|
||||||
const finalLoosePills = Math.max(0, editStockLoosePills);
|
const finalLoosePills = Math.max(0, editStockLoosePills);
|
||||||
|
|
||||||
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
// 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);
|
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
||||||
finalPartialPills %= selectedMed.pillsPerBlister;
|
finalPartialPills %= selectedMed.pillsPerBlister;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structural max = sealed package capacity only (no looseTablets offset).
|
// Structural max = sealed package capacity only (no looseTablets offset).
|
||||||
const structuralMax =
|
const structuralMax = isAmountPackage
|
||||||
selectedMed.packageType === "bottle"
|
|
||||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
: 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.
|
// For blister meds, only sealed pills are capped to package size.
|
||||||
// Loose pills are extra and can be above package size.
|
// Loose pills are extra and can be above package size.
|
||||||
const desiredTotal =
|
let desiredTotal: number;
|
||||||
selectedMed.packageType === "bottle"
|
if (isTubePackage) {
|
||||||
? Math.min(structuralMax, Math.max(0, finalPartialPills))
|
desiredTotal = Math.max(0, finalPartialPills);
|
||||||
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
} else if (isAmountPackage) {
|
||||||
|
desiredTotal = Math.min(liquidStructuralMax, Math.max(0, finalPartialPills));
|
||||||
|
} else {
|
||||||
|
desiredTotal =
|
||||||
|
Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||||
finalLoosePills;
|
finalLoosePills;
|
||||||
|
}
|
||||||
|
|
||||||
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
||||||
// - Bottle: looseTablets is the base (not changed during correction)
|
// - Bottle: looseTablets is the base (not changed during correction)
|
||||||
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
||||||
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
||||||
const baseTotal =
|
let baseTotal: number;
|
||||||
selectedMed.packageType === "bottle"
|
if (isLiquidPackage) {
|
||||||
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
|
baseTotal = liquidStructuralMax;
|
||||||
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
} 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
|
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||||
const newStockAdjustment = desiredTotal - baseTotal;
|
const newStockAdjustment = desiredTotal - baseTotal;
|
||||||
|
|
||||||
// For blister corrections also send the new looseTablets value so the DB
|
// For blister corrections also send the new looseTablets value so the DB
|
||||||
// reflects the actual loose count (avoids stale-split display on reload).
|
// 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,
|
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;
|
patchBody.looseTablets = finalLoosePills;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +268,10 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockMedication(selectedMed);
|
setEditStockMedication(selectedMed);
|
||||||
|
const isAmountPackage =
|
||||||
|
selectedMed.packageType === "bottle" ||
|
||||||
|
selectedMed.packageType === "tube" ||
|
||||||
|
selectedMed.packageType === "liquid_container";
|
||||||
// Get current stock from coverage (after consumption)
|
// Get current stock from coverage (after consumption)
|
||||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
@@ -231,15 +281,20 @@ export function useRefill(): UseRefillReturn {
|
|||||||
// For blister, keep loose pills separated from sealed blister/partial counts.
|
// For blister, keep loose pills separated from sealed blister/partial counts.
|
||||||
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
||||||
const sealedPills = Math.max(0, currentStock - knownLoose);
|
const sealedPills = Math.max(0, currentStock - knownLoose);
|
||||||
const fullBlisters =
|
let fullBlisters: number;
|
||||||
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
if (selectedMed.packageType === "liquid_container") {
|
||||||
const partialPills =
|
fullBlisters = Math.max(1, selectedMed.packCount);
|
||||||
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
} 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
|
// Pre-fill with current values
|
||||||
setEditStockFullBlisters(fullBlisters);
|
setEditStockFullBlisters(fullBlisters);
|
||||||
setEditStockPartialBlisterPills(partialPills);
|
setEditStockPartialBlisterPills(partialPills);
|
||||||
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
|
setEditStockLoosePills(isAmountPackage ? 0 : knownLoose);
|
||||||
setShowEditStockModal(true);
|
setShowEditStockModal(true);
|
||||||
window.history.pushState({ modal: "editStock" }, "");
|
window.history.pushState({ modal: "editStock" }, "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"fullBlisters": "Volle Blister",
|
"fullBlisters": "Volle Blister",
|
||||||
"openBlister": "Offener Blister",
|
"openBlister": "Offener Blister",
|
||||||
"stock": "Bestand",
|
"stock": "Bestand",
|
||||||
|
"dailyConsumption": "Taeglicher Verbrauch",
|
||||||
"stockDetails": "Details",
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -118,7 +119,8 @@
|
|||||||
"expiry": "Ablaufdatum",
|
"expiry": "Ablaufdatum",
|
||||||
"pillsCount": "{{count}} Tabletten",
|
"pillsCount": "{{count}} Tabletten",
|
||||||
"pillsCount_one": "{{count}} Tablette",
|
"pillsCount_one": "{{count}} Tablette",
|
||||||
"pillsCount_other": "{{count}} Tabletten"
|
"pillsCount_other": "{{count}} Tabletten",
|
||||||
|
"perDayWithUnit": "{{value}} {{unit}}"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -130,7 +132,8 @@
|
|||||||
"reactivate": "Reaktivieren",
|
"reactivate": "Reaktivieren",
|
||||||
"obsoleteTitle": "Obsolet ({{count}})",
|
"obsoleteTitle": "Obsolet ({{count}})",
|
||||||
"obsoleteSince": "Beendet",
|
"obsoleteSince": "Beendet",
|
||||||
"started": "Gestartet"
|
"started": "Gestartet",
|
||||||
|
"emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
@@ -139,6 +142,7 @@
|
|||||||
"loose": "Lose",
|
"loose": "Lose",
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
"stock": "Bestand",
|
"stock": "Bestand",
|
||||||
|
"capacityPerPackage": "Kapazitaet pro Packung",
|
||||||
"totalCapacity": "Kapazität",
|
"totalCapacity": "Kapazität",
|
||||||
"type": "Typ"
|
"type": "Typ"
|
||||||
},
|
},
|
||||||
@@ -182,6 +186,7 @@
|
|||||||
"packageTypeTube": "Tube",
|
"packageTypeTube": "Tube",
|
||||||
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
|
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
|
"bottles": "Flaschen",
|
||||||
"tubes": "Tuben",
|
"tubes": "Tuben",
|
||||||
"blistersPerPack": "Blister pro Packung",
|
"blistersPerPack": "Blister pro Packung",
|
||||||
"pillsPerBlister": "Tabletten pro Blister",
|
"pillsPerBlister": "Tabletten pro Blister",
|
||||||
@@ -191,6 +196,7 @@
|
|||||||
"currentAmount": "Aktuelle Menge",
|
"currentAmount": "Aktuelle Menge",
|
||||||
"totalAmountLabel": "Gesamt (Menge)",
|
"totalAmountLabel": "Gesamt (Menge)",
|
||||||
"packageAmount": "Packungsinhalt",
|
"packageAmount": "Packungsinhalt",
|
||||||
|
"packageAmountPerBottle": "Inhalt pro Flasche",
|
||||||
"packageAmountPerTube": "Inhalt pro Tube",
|
"packageAmountPerTube": "Inhalt pro Tube",
|
||||||
"packageAmountUnitMl": "ml",
|
"packageAmountUnitMl": "ml",
|
||||||
"packageAmountUnitG": "g",
|
"packageAmountUnitG": "g",
|
||||||
@@ -232,12 +238,25 @@
|
|||||||
"usageTablets": "Dosis (Tabletten)",
|
"usageTablets": "Dosis (Tabletten)",
|
||||||
"usageCapsules": "Dosis (Kapseln)",
|
"usageCapsules": "Dosis (Kapseln)",
|
||||||
"usageMl": "Dosis (ml)",
|
"usageMl": "Dosis (ml)",
|
||||||
|
"usageTsp": "Dosis (tsp)",
|
||||||
|
"usageTbsp": "Dosis (tbsp)",
|
||||||
"usageApplication": "Dosis (Anwendungen)",
|
"usageApplication": "Dosis (Anwendungen)",
|
||||||
"intakeUnit": "Einnahmeeinheit",
|
"intakeUnit": "Einnahmeeinheit",
|
||||||
"intakeUnitMl": "Milliliter (ml)",
|
"intakeUnitMl": "Milliliter (ml)",
|
||||||
"intakeUnitTsp": "Teeloeffel (5 ml)",
|
"intakeUnitTsp": "Teeloeffel (5 ml)",
|
||||||
"intakeUnitTbsp": "Essloeffel (15 ml)",
|
"intakeUnitTbsp": "Essloeffel (15 ml)",
|
||||||
|
"intakes": "Einnahmen",
|
||||||
|
"intakes_one": "Einnahme",
|
||||||
|
"intakes_other": "Einnahmen",
|
||||||
|
"teaspoons": "Teeloeffel",
|
||||||
|
"teaspoons_one": "Teeloeffel",
|
||||||
|
"teaspoons_other": "Teeloeffel",
|
||||||
|
"tablespoons": "Essloeffel",
|
||||||
|
"tablespoons_one": "Essloeffel",
|
||||||
|
"tablespoons_other": "Essloeffel",
|
||||||
"applications": "Anwendungen",
|
"applications": "Anwendungen",
|
||||||
|
"applications_one": "Anwendung",
|
||||||
|
"applications_other": "Anwendungen",
|
||||||
"everyDays": "Alle (Tage)",
|
"everyDays": "Alle (Tage)",
|
||||||
"every": "alle",
|
"every": "alle",
|
||||||
"from": "ab",
|
"from": "ab",
|
||||||
@@ -330,7 +349,8 @@
|
|||||||
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
||||||
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
||||||
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
||||||
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
|
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen",
|
||||||
|
"packageTypesNote": "Hinweis: Tubenmedikamente sind von Bestands-Erinnerungen ausgeschlossen. Flüssigbehälter-Medikamente verwenden einen einzelnen Reminder-Basiswert (Niedrig und Kritisch werden automatisch von diesem Wert abgeleitet)."
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "Allgemeine UI",
|
"title": "Allgemeine UI",
|
||||||
@@ -347,7 +367,7 @@
|
|||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Bestands-Erinnerung",
|
"title": "Bestands-Erinnerung",
|
||||||
"description": "Bestands-Erinnerungen aktivieren",
|
"description": "Bestands-Erinnerungen aktivieren",
|
||||||
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Niedrig: Bestand wird knapp. Kritisch: Bestand ist kritisch niedrig — bald nachbestellen.",
|
"infoTooltip": "Benachrichtigungen umfassen alle Medikamente mit Niedrig- oder Kritisch-Status. Hinweis: Tubenmedikamente sind ausgeschlossen; Flüssigbehälter verwenden einen einzelnen Basiswert (Niedrig und Kritisch werden abgeleitet).",
|
||||||
"repeatDaily": "Täglich wiederholen",
|
"repeatDaily": "Täglich wiederholen",
|
||||||
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
|
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
|
||||||
},
|
},
|
||||||
@@ -358,6 +378,8 @@
|
|||||||
"at": "um",
|
"at": "um",
|
||||||
"stockInfo": "Aktueller Bestand",
|
"stockInfo": "Aktueller Bestand",
|
||||||
"packageDetails": "Packungsdetails",
|
"packageDetails": "Packungsdetails",
|
||||||
|
"packageTypeTubeHint": "Tubenmedikamente enthalten feste Mengen (z. B. Cremes, Gele). Der Bestand wird nicht verfolgt und Erinnerungen werden nicht gesendet.",
|
||||||
|
"packageTypeLiquidHint": "Flüssigbehälter verwenden ein vereinfachtes Erinnerungsmodell. Niedrig- und Kritisch-Stufen werden automatisch von einem einzelnen Basiswert abgeleitet.",
|
||||||
"currentStock": "Tabletten",
|
"currentStock": "Tabletten",
|
||||||
"packs": "Packungen",
|
"packs": "Packungen",
|
||||||
"blistersPerPack": "Blister/Packung",
|
"blistersPerPack": "Blister/Packung",
|
||||||
@@ -608,9 +630,11 @@
|
|||||||
"loosePills": "Lose Tabletten",
|
"loosePills": "Lose Tabletten",
|
||||||
"pillsPerBlister": "(je {{count}} Tabletten)",
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
||||||
|
"packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}",
|
||||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
||||||
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
||||||
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
||||||
|
"maxExceededAmount": "Die maximale Packungsgroesse betraegt {{count}} {{unit}}. Werte wurden begrenzt.",
|
||||||
"decreaseValue": "Wert verringern",
|
"decreaseValue": "Wert verringern",
|
||||||
"increaseValue": "Wert erhöhen",
|
"increaseValue": "Wert erhöhen",
|
||||||
"currentTotal": "Aktueller Bestand",
|
"currentTotal": "Aktueller Bestand",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
"fullBlisters": "Full blisters",
|
"fullBlisters": "Full blisters",
|
||||||
"openBlister": "Open blister",
|
"openBlister": "Open blister",
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
|
"dailyConsumption": "Daily consumption",
|
||||||
"stockDetails": "Details",
|
"stockDetails": "Details",
|
||||||
"daysLeft": "Days left",
|
"daysLeft": "Days left",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -118,7 +119,8 @@
|
|||||||
"expiry": "Expiry",
|
"expiry": "Expiry",
|
||||||
"pillsCount": "{{count}} pills",
|
"pillsCount": "{{count}} pills",
|
||||||
"pillsCount_one": "{{count}} pill",
|
"pillsCount_one": "{{count}} pill",
|
||||||
"pillsCount_other": "{{count}} pills"
|
"pillsCount_other": "{{count}} pills",
|
||||||
|
"perDayWithUnit": "{{value}} {{unit}}"
|
||||||
},
|
},
|
||||||
"medications": {
|
"medications": {
|
||||||
"list": {
|
"list": {
|
||||||
@@ -130,7 +132,8 @@
|
|||||||
"reactivate": "Reactivate",
|
"reactivate": "Reactivate",
|
||||||
"obsoleteTitle": "Obsolete ({{count}})",
|
"obsoleteTitle": "Obsolete ({{count}})",
|
||||||
"obsoleteSince": "Stopped",
|
"obsoleteSince": "Stopped",
|
||||||
"started": "Started"
|
"started": "Started",
|
||||||
|
"emptyState": "No medications yet. Add your first medication to get started."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
@@ -139,6 +142,7 @@
|
|||||||
"loose": "Loose",
|
"loose": "Loose",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"stock": "Stock",
|
"stock": "Stock",
|
||||||
|
"capacityPerPackage": "Capacity per package",
|
||||||
"totalCapacity": "Capacity",
|
"totalCapacity": "Capacity",
|
||||||
"type": "Type"
|
"type": "Type"
|
||||||
},
|
},
|
||||||
@@ -182,6 +186,7 @@
|
|||||||
"packageTypeTube": "Tube",
|
"packageTypeTube": "Tube",
|
||||||
"packageTypeLiquidContainer": "Liquid Container",
|
"packageTypeLiquidContainer": "Liquid Container",
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
|
"bottles": "Bottles",
|
||||||
"tubes": "Tubes",
|
"tubes": "Tubes",
|
||||||
"blistersPerPack": "Blisters per pack",
|
"blistersPerPack": "Blisters per pack",
|
||||||
"pillsPerBlister": "Pills per blister",
|
"pillsPerBlister": "Pills per blister",
|
||||||
@@ -191,6 +196,7 @@
|
|||||||
"currentAmount": "Current Amount",
|
"currentAmount": "Current Amount",
|
||||||
"totalAmountLabel": "Total (amount)",
|
"totalAmountLabel": "Total (amount)",
|
||||||
"packageAmount": "Package amount",
|
"packageAmount": "Package amount",
|
||||||
|
"packageAmountPerBottle": "Amount per bottle",
|
||||||
"packageAmountPerTube": "Amount per tube",
|
"packageAmountPerTube": "Amount per tube",
|
||||||
"packageAmountUnitMl": "ml",
|
"packageAmountUnitMl": "ml",
|
||||||
"packageAmountUnitG": "g",
|
"packageAmountUnitG": "g",
|
||||||
@@ -232,12 +238,25 @@
|
|||||||
"usageTablets": "Usage (tablets)",
|
"usageTablets": "Usage (tablets)",
|
||||||
"usageCapsules": "Usage (capsules)",
|
"usageCapsules": "Usage (capsules)",
|
||||||
"usageMl": "Usage (ml)",
|
"usageMl": "Usage (ml)",
|
||||||
|
"usageTsp": "Usage (tsp)",
|
||||||
|
"usageTbsp": "Usage (tbsp)",
|
||||||
"usageApplication": "Usage (applications)",
|
"usageApplication": "Usage (applications)",
|
||||||
"intakeUnit": "Intake unit",
|
"intakeUnit": "Intake unit",
|
||||||
"intakeUnitMl": "Milliliters (ml)",
|
"intakeUnitMl": "Milliliters (ml)",
|
||||||
"intakeUnitTsp": "Teaspoon (5 ml)",
|
"intakeUnitTsp": "Teaspoon (5 ml)",
|
||||||
"intakeUnitTbsp": "Tablespoon (15 ml)",
|
"intakeUnitTbsp": "Tablespoon (15 ml)",
|
||||||
|
"intakes": "intakes",
|
||||||
|
"intakes_one": "intake",
|
||||||
|
"intakes_other": "intakes",
|
||||||
|
"teaspoons": "teaspoons",
|
||||||
|
"teaspoons_one": "teaspoon",
|
||||||
|
"teaspoons_other": "teaspoons",
|
||||||
|
"tablespoons": "tablespoons",
|
||||||
|
"tablespoons_one": "tablespoon",
|
||||||
|
"tablespoons_other": "tablespoons",
|
||||||
"applications": "applications",
|
"applications": "applications",
|
||||||
|
"applications_one": "application",
|
||||||
|
"applications_other": "applications",
|
||||||
"everyDays": "Every (days)",
|
"everyDays": "Every (days)",
|
||||||
"every": "every",
|
"every": "every",
|
||||||
"from": "from",
|
"from": "from",
|
||||||
@@ -330,7 +349,8 @@
|
|||||||
"highStockTooltip": "Stock above this value means you are well supplied",
|
"highStockTooltip": "Stock above this value means you are well supplied",
|
||||||
"thresholdValidation": "Values must be: Critical < Low < High",
|
"thresholdValidation": "Values must be: Critical < Low < High",
|
||||||
"shareStockStatus": "Show Stock on Shared Links",
|
"shareStockStatus": "Show Stock on Shared Links",
|
||||||
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
|
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users",
|
||||||
|
"packageTypesNote": "Note: Tube medications are excluded from stock reminders. Liquid container medications use a single reminder baseline (Low and Critical are automatically derived from this value)."
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "General UI",
|
"title": "General UI",
|
||||||
@@ -347,7 +367,7 @@
|
|||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Stock Reminder",
|
"title": "Stock Reminder",
|
||||||
"description": "Enable stock reminders",
|
"description": "Enable stock reminders",
|
||||||
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Low: stock is running low. Critical: stock is critically low — reorder soon.",
|
"infoTooltip": "Notifications include all medications with Low or Critical stock status. Note: Tube medications are excluded; Liquid containers use a single baseline threshold (Low and Critical are derived).",
|
||||||
"repeatDaily": "Repeat daily",
|
"repeatDaily": "Repeat daily",
|
||||||
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
|
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
|
||||||
},
|
},
|
||||||
@@ -358,6 +378,8 @@
|
|||||||
"at": "at",
|
"at": "at",
|
||||||
"stockInfo": "Current Stock",
|
"stockInfo": "Current Stock",
|
||||||
"packageDetails": "Package Details",
|
"packageDetails": "Package Details",
|
||||||
|
"packageTypeTubeHint": "Tube medications contain fixed amounts (e.g., creams, gels). Stock is not tracked and reminders are not sent.",
|
||||||
|
"packageTypeLiquidHint": "Liquid containers use a simplified reminder model. Low and Critical levels are automatically derived from a single baseline threshold for simplicity.",
|
||||||
"currentStock": "Pills",
|
"currentStock": "Pills",
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
"blistersPerPack": "Blisters/Pack",
|
"blistersPerPack": "Blisters/Pack",
|
||||||
@@ -608,9 +630,11 @@
|
|||||||
"loosePills": "Loose pills",
|
"loosePills": "Loose pills",
|
||||||
"pillsPerBlister": "({{count}} pills each)",
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
"packageSize": "Package size: {{count}} pills",
|
"packageSize": "Package size: {{count}} pills",
|
||||||
|
"packageSizeAmount": "Package size: {{count}} {{unit}}",
|
||||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
||||||
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
||||||
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
||||||
|
"maxExceededAmount": "Maximum package size is {{count}} {{unit}}. Values were capped.",
|
||||||
"decreaseValue": "Decrease value",
|
"decreaseValue": "Decrease value",
|
||||||
"increaseValue": "Increase value",
|
"increaseValue": "Increase value",
|
||||||
"currentTotal": "Current total",
|
"currentTotal": "Current total",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function DashboardPage() {
|
|||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
coverage.low,
|
coverage.low,
|
||||||
coverage.all,
|
coverage.all,
|
||||||
|
meds,
|
||||||
settings.lastAutoEmailSent,
|
settings.lastAutoEmailSent,
|
||||||
settings.lastNotificationType,
|
settings.lastNotificationType,
|
||||||
settings.lastNotificationChannel,
|
settings.lastNotificationChannel,
|
||||||
@@ -130,41 +131,157 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||||
|
|
||||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined) =>
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
||||||
? t("form.ml")
|
? t("form.packageAmountUnitMl")
|
||||||
: t("blisters.applications");
|
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||||
|
|
||||||
const formatStockLabel = (med: (typeof meds)[number] | undefined, medsLeft: number) => {
|
const formatStockLabel = (med: (typeof meds)[number] | undefined, medsLeft: number) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (med?.packageType === "liquid_container") {
|
||||||
return `${formatNumber(medsLeft)} ${t("form.ml")}`;
|
return `${formatNumber(medsLeft)} ${t("form.packageAmountUnitMl")}`;
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (med?.packageType === "tube") {
|
||||||
return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med)}`;
|
return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med, medsLeft)}`;
|
||||||
}
|
}
|
||||||
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDoseUsageLabel = (med: (typeof meds)[number] | undefined, usage: number) => {
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (med?.packageType === "liquid_container") {
|
||||||
return `${usage} ${t("form.ml")}`;
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (med?.packageType === "tube") {
|
||||||
return `${usage} ${getTubeUnitLabel(med)}`;
|
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
||||||
}
|
}
|
||||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTotalUsageLabel = (med: (typeof meds)[number] | undefined, total: number) => {
|
const formatTotalUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
total: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (med?.packageType === "liquid_container") {
|
||||||
return `${total} ${t("form.ml")}`;
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLiquidUsageLabel(total, intakeUnit);
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (med?.packageType === "tube") {
|
||||||
return `${total} ${getTubeUnitLabel(med)}`;
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
}
|
}
|
||||||
return t("common.pillsTotal", { count: total });
|
return t("common.pillsTotal", { count: total });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
|
||||||
|
if (!med) return "-";
|
||||||
|
|
||||||
|
const intakes =
|
||||||
|
med.intakes && med.intakes.length > 0
|
||||||
|
? med.intakes
|
||||||
|
: med.blisters.map((blister) => ({
|
||||||
|
usage: blister.usage,
|
||||||
|
every: blister.every,
|
||||||
|
intakeUnit: null as "ml" | "tsp" | "tbsp" | null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (intakes.length === 0) return "-";
|
||||||
|
|
||||||
|
let dailyTotal = 0;
|
||||||
|
for (const intake of intakes) {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
const every = Math.max(1, Number(intake.every) || 1);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) continue;
|
||||||
|
|
||||||
|
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0;
|
||||||
|
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
||||||
|
const normalizedUsage = (usage * personMultiplier) / every;
|
||||||
|
|
||||||
|
if (med.packageType === "liquid_container") {
|
||||||
|
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
||||||
|
} else {
|
||||||
|
dailyTotal += normalizedUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dailyTotal <= 0) return "-";
|
||||||
|
|
||||||
|
if (med.packageType === "liquid_container") {
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: t("form.packageAmountUnitMl") });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
const tubeUnit =
|
||||||
|
med.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.blisters.applications", { count: Math.abs(dailyTotal) });
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: tubeUnit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillUnit = dailyTotal === 1 ? t("common.pill") : t("common.pills");
|
||||||
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: pillUnit });
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getVisibleStockStatus = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
|
||||||
|
|
||||||
|
const getMedByName = (name: string) => meds.find((m) => getMedDisplayName(m) === name);
|
||||||
|
|
||||||
const prescriptionStatus =
|
const prescriptionStatus =
|
||||||
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
|
prescriptionRemindersEnabled && prescriptionLowMeds.length > 0
|
||||||
? {
|
? {
|
||||||
@@ -289,7 +406,9 @@ export function DashboardPage() {
|
|||||||
{reminderData.lowStockMeds.map((med, idx) => {
|
{reminderData.lowStockMeds.map((med, idx) => {
|
||||||
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
|
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
|
||||||
const cov = coverage.all.find((c) => c.name === med.name);
|
const cov = coverage.all.find((c) => c.name === med.name);
|
||||||
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
|
const status = cov
|
||||||
|
? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType)
|
||||||
|
: null;
|
||||||
const textClass =
|
const textClass =
|
||||||
status?.className === "danger"
|
status?.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
@@ -447,7 +566,9 @@ export function DashboardPage() {
|
|||||||
const lowStockMap = new Map<string, Coverage>();
|
const lowStockMap = new Map<string, Coverage>();
|
||||||
for (const c of coverage.all) {
|
for (const c of coverage.all) {
|
||||||
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
|
if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
|
||||||
if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
|
const med = getMedByName(c.name);
|
||||||
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
if (status.className === "danger" || status.className === "warning") {
|
||||||
const existing = lowStockMap.get(c.name);
|
const existing = lowStockMap.get(c.name);
|
||||||
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
|
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
|
||||||
lowStockMap.set(c.name, c);
|
lowStockMap.set(c.name, c);
|
||||||
@@ -468,7 +589,7 @@ export function DashboardPage() {
|
|||||||
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
||||||
{lowStockMeds.map((c, idx) => {
|
{lowStockMeds.map((c, idx) => {
|
||||||
const med = meds.find((m) => getMedDisplayName(m) === c.name);
|
const med = meds.find((m) => getMedDisplayName(m) === c.name);
|
||||||
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
status.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
@@ -512,10 +633,11 @@ export function DashboardPage() {
|
|||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("dashboard.overview.title")}</h2>
|
<h2>{t("dashboard.overview.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="table table-7">
|
<div className="table table-8">
|
||||||
<div className="table-head">
|
<div className="table-head">
|
||||||
<span>{t("table.name")}</span>
|
<span>{t("table.name")}</span>
|
||||||
<span>{t("table.stock")}</span>
|
<span>{t("table.stock")}</span>
|
||||||
|
<span>{t("table.dailyConsumption")}</span>
|
||||||
<span>{t("table.stockDetails")}</span>
|
<span>{t("table.stockDetails")}</span>
|
||||||
<span>{t("table.daysLeft")}</span>
|
<span>{t("table.daysLeft")}</span>
|
||||||
<span>{t("table.runsOut")}</span>
|
<span>{t("table.runsOut")}</span>
|
||||||
@@ -523,13 +645,14 @@ export function DashboardPage() {
|
|||||||
<span>{t("table.status")}</span>
|
<span>{t("table.status")}</span>
|
||||||
</div>
|
</div>
|
||||||
{coverage.all.map((row) => {
|
{coverage.all.map((row) => {
|
||||||
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
|
|
||||||
const med = meds.find((m) => getMedDisplayName(m) === row.name);
|
const med = meds.find((m) => getMedDisplayName(m) === row.name);
|
||||||
|
const rawStatus = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
rawStatus.className === "danger"
|
||||||
? "danger-text"
|
? "danger-text"
|
||||||
: status.className === "warning"
|
: rawStatus.className === "warning"
|
||||||
? "warning-text"
|
? "warning-text"
|
||||||
: "success-text";
|
: "success-text";
|
||||||
const stock = getBlisterStock(
|
const stock = getBlisterStock(
|
||||||
@@ -629,6 +752,9 @@ export function DashboardPage() {
|
|||||||
? formatStockLabel(med, row.medsLeft)
|
? formatStockLabel(med, row.medsLeft)
|
||||||
: formatFullBlisters(stock.fullBlisters, t)}
|
: formatFullBlisters(stock.fullBlisters, t)}
|
||||||
</span>
|
</span>
|
||||||
|
<span data-label={t("table.dailyConsumption")} className={textClass}>
|
||||||
|
{formatDailyConsumption(med)}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
data-label={t("table.stockDetails")}
|
data-label={t("table.stockDetails")}
|
||||||
className={`${textClass}${med?.packageType === "bottle" || med?.packageType === "tube" || med?.packageType === "liquid_container" ? " hide-on-card" : ""}`}
|
className={`${textClass}${med?.packageType === "bottle" || med?.packageType === "tube" || med?.packageType === "liquid_container" ? " hide-on-card" : ""}`}
|
||||||
@@ -657,8 +783,8 @@ export function DashboardPage() {
|
|||||||
})
|
})
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
|
<span data-label={t("table.status")} className={status ? `status-chip ${status.className}` : ""}>
|
||||||
{t(status.label)}
|
{status ? t(status.label) : "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -775,9 +901,10 @@ export function DashboardPage() {
|
|||||||
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCov = coverageByMed[item.medName];
|
const medCov = coverageByMed[item.medName];
|
||||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||||
const status = medCov
|
const rawStatus = medCov
|
||||||
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds)
|
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
|
||||||
: null;
|
: null;
|
||||||
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -812,7 +939,9 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
<span className="tag subtle">
|
||||||
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
|
</span>
|
||||||
{status && (
|
{status && (
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -826,7 +955,9 @@ export function DashboardPage() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
<span className="dose-usage-main">
|
||||||
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{med?.packageType !== "tube" &&
|
||||||
med?.packageType !== "liquid_container" &&
|
med?.packageType !== "liquid_container" &&
|
||||||
med?.pillWeightMg && (
|
med?.pillWeightMg && (
|
||||||
@@ -986,7 +1117,13 @@ export function DashboardPage() {
|
|||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
if (willBeOutOfStock) return "danger";
|
if (willBeOutOfStock) return "danger";
|
||||||
if (!medCoverage) return "success";
|
if (!medCoverage) return "success";
|
||||||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
|
const med = getMedByName(item.medName);
|
||||||
|
const status = getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -1036,8 +1173,14 @@ export function DashboardPage() {
|
|||||||
const status = willBeOutOfStock
|
const status = willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -1072,9 +1215,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1090,7 +1237,9 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
<span className="dose-usage-main">
|
||||||
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{med?.packageType !== "tube" &&
|
||||||
med?.packageType !== "liquid_container" &&
|
med?.packageType !== "liquid_container" &&
|
||||||
med?.pillWeightMg && (
|
med?.pillWeightMg && (
|
||||||
@@ -1218,7 +1367,13 @@ export function DashboardPage() {
|
|||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
if (willBeOutOfStock) return "danger";
|
if (willBeOutOfStock) return "danger";
|
||||||
if (!medCoverage) return "success";
|
if (!medCoverage) return "success";
|
||||||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
|
const med = getMedByName(item.medName);
|
||||||
|
const status = getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
const worstStatus = dayStockStatuses.includes("danger")
|
const worstStatus = dayStockStatuses.includes("danger")
|
||||||
@@ -1267,8 +1422,14 @@ export function DashboardPage() {
|
|||||||
const status = willBeOutOfStock
|
const status = willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -1303,9 +1464,13 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{formatTotalUsageLabel(med, item.total)}</span>
|
<span className="tag subtle">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1317,7 +1482,9 @@ export function DashboardPage() {
|
|||||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">{formatDoseUsageLabel(med, dose.usage)}</span>
|
<span className="dose-usage-main">
|
||||||
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{med?.packageType !== "tube" &&
|
||||||
med?.packageType !== "liquid_container" &&
|
med?.packageType !== "liquid_container" &&
|
||||||
med?.pillWeightMg && (
|
med?.pillWeightMg && (
|
||||||
|
|||||||
@@ -295,6 +295,37 @@ export function MedicationsPage() {
|
|||||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||||
|
|
||||||
|
const getMedicationPackageTypeLabel = useCallback(
|
||||||
|
(med: Medication) => {
|
||||||
|
if (med.packageType === "bottle") return t("form.packageTypeBottle");
|
||||||
|
if (med.packageType === "tube") return t("form.packageTypeTube");
|
||||||
|
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
||||||
|
return t("form.packageTypeBlister");
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMedicationStockSuffix = useCallback(
|
||||||
|
(med: Medication) => {
|
||||||
|
if (med.packageType === "tube") return "";
|
||||||
|
if (med.packageType === "liquid_container") return " ml";
|
||||||
|
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMedicationUsageUnitLabel = useCallback(
|
||||||
|
(med: Medication, usage: number) => {
|
||||||
|
if (med.packageType === "tube") {
|
||||||
|
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
|
||||||
|
}
|
||||||
|
if (med.packageType === "liquid_container") return "ml";
|
||||||
|
if (usage === 1) return t("common.pill");
|
||||||
|
return t("common.pills");
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const clearEditMedIdParam = useCallback(() => {
|
const clearEditMedIdParam = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prevParams) => {
|
(prevParams) => {
|
||||||
@@ -507,18 +538,26 @@ export function MedicationsPage() {
|
|||||||
const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills);
|
const remainingRefills = Math.min(Number(form.prescriptionRemainingRefills || 0), authorizedRefills);
|
||||||
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
||||||
|
|
||||||
const derivedMedicationForm =
|
let derivedMedicationForm: string;
|
||||||
form.packageType === "tube"
|
if (form.packageType === "tube") {
|
||||||
? form.medicationForm === "liquid" || form.medicationForm === "topical"
|
derivedMedicationForm =
|
||||||
? form.medicationForm
|
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
|
||||||
: "topical"
|
} else if (form.packageType === "liquid_container") {
|
||||||
: form.packageType === "liquid_container"
|
derivedMedicationForm = "liquid";
|
||||||
? "liquid"
|
} else {
|
||||||
: form.pillForm;
|
derivedMedicationForm = form.pillForm;
|
||||||
|
}
|
||||||
|
|
||||||
const tubeTotalAmount =
|
const tubeTotalAmount =
|
||||||
form.packageType === "tube" ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null;
|
form.packageType === "tube" ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null;
|
||||||
|
|
||||||
|
let packageAmountUnit = form.packageAmountUnit ?? "ml";
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
packageAmountUnit = "g";
|
||||||
|
} else if (form.packageType === "liquid_container") {
|
||||||
|
packageAmountUnit = "ml";
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
genericName: form.genericName.trim() || null,
|
genericName: form.genericName.trim() || null,
|
||||||
@@ -531,12 +570,7 @@ export function MedicationsPage() {
|
|||||||
blistersPerPack: form.packageType === "tube" ? 1 : Number(form.blistersPerPack) || 1,
|
blistersPerPack: form.packageType === "tube" ? 1 : Number(form.blistersPerPack) || 1,
|
||||||
pillsPerBlister: form.packageType === "tube" ? 1 : Number(form.pillsPerBlister) || 1,
|
pillsPerBlister: form.packageType === "tube" ? 1 : Number(form.pillsPerBlister) || 1,
|
||||||
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
||||||
packageAmountUnit:
|
packageAmountUnit,
|
||||||
form.packageType === "tube"
|
|
||||||
? "g"
|
|
||||||
: form.packageType === "liquid_container"
|
|
||||||
? "ml"
|
|
||||||
: (form.packageAmountUnit ?? "ml"),
|
|
||||||
totalPills: form.packageType === "tube" ? tubeTotalAmount : Number(form.totalPills) || null,
|
totalPills: form.packageType === "tube" ? tubeTotalAmount : Number(form.totalPills) || null,
|
||||||
looseTablets: form.packageType === "tube" ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
looseTablets: form.packageType === "tube" ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
||||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||||
@@ -940,16 +974,7 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="med-details">
|
<div className="med-details">
|
||||||
<span>
|
<span>
|
||||||
{t("medications.details.type")}:{" "}
|
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
|
||||||
<strong>
|
|
||||||
{med.packageType === "bottle"
|
|
||||||
? t("form.packageTypeBottle")
|
|
||||||
: med.packageType === "tube"
|
|
||||||
? t("form.packageTypeTube")
|
|
||||||
: med.packageType === "liquid_container"
|
|
||||||
? t("form.packageTypeLiquidContainer")
|
|
||||||
: t("form.packageTypeBlister")}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
{med.packageType === "blister" ? (
|
{med.packageType === "blister" ? (
|
||||||
<>
|
<>
|
||||||
@@ -984,11 +1009,7 @@ export function MedicationsPage() {
|
|||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
: getPackageSize(med)}{" "}
|
: getPackageSize(med)}{" "}
|
||||||
/ {getPackageSize(med)}
|
/ {getPackageSize(med)}
|
||||||
{med.packageType === "tube"
|
{med.packageType === "tube" ? "" : getMedicationStockSuffix(med)}
|
||||||
? ""
|
|
||||||
: med.packageType === "liquid_container"
|
|
||||||
? " ml"
|
|
||||||
: ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`}
|
|
||||||
{(coverageByMed[getMedDisplayName(med)]
|
{(coverageByMed[getMedDisplayName(med)]
|
||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
: getPackageSize(med)) > getPackageSize(med) && (
|
: getPackageSize(med)) > getPackageSize(med) && (
|
||||||
@@ -1006,17 +1027,8 @@ export function MedicationsPage() {
|
|||||||
<div className="blister-list">
|
<div className="blister-list">
|
||||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||||
{s.usage}{" "}
|
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "}
|
||||||
{med.packageType === "tube"
|
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||||
? med.medicationForm === "liquid"
|
|
||||||
? "ml"
|
|
||||||
: t("form.blisters.usageApplication")
|
|
||||||
: med.packageType === "liquid_container"
|
|
||||||
? "ml"
|
|
||||||
: s.usage === 1
|
|
||||||
? t("common.pill")
|
|
||||||
: t("common.pills")}{" "}
|
|
||||||
· {s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
|
||||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||||
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||||
@@ -1405,7 +1417,9 @@ export function MedicationsPage() {
|
|||||||
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{(() => {
|
||||||
|
if (form.packageType === "blister") {
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.packs")}
|
{t("form.packs")}
|
||||||
@@ -1442,7 +1456,11 @@ export function MedicationsPage() {
|
|||||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : form.packageType === "tube" ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.packageType === "tube") {
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.tubes")}
|
{t("form.tubes")}
|
||||||
@@ -1478,12 +1496,17 @@ export function MedicationsPage() {
|
|||||||
<label>
|
<label>
|
||||||
{t("form.totalAmount")}
|
{t("form.totalAmount")}
|
||||||
<div className="static-value">
|
<div className="static-value">
|
||||||
{formatNumber((Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0))}
|
{formatNumber(
|
||||||
|
(Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)
|
||||||
|
)}
|
||||||
{t("form.packageAmountUnitG")}
|
{t("form.packageAmountUnitG")}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{totalCapacityLabel}
|
{totalCapacityLabel}
|
||||||
@@ -1506,7 +1529,8 @@ export function MedicationsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "../components/Auth";
|
|||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import type { Coverage } from "../types";
|
||||||
import { getMedDisplayName } from "../types";
|
import { getMedDisplayName } from "../types";
|
||||||
|
import { formatNumber } from "../utils/formatters";
|
||||||
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
@@ -17,12 +18,21 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
||||||
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
|
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
||||||
// Out of stock or completely depleted = danger (red)
|
// Out of stock or completely depleted = danger (red)
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
// No schedule, but has stock = normal
|
// No schedule, but has stock = normal
|
||||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const lowDays = Math.max(1, Math.floor(settings.reminderDaysBefore));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
return { className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
// Critical: at or below reminder threshold = danger (red)
|
// Critical: at or below reminder threshold = danger (red)
|
||||||
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
||||||
// Low: below low stock threshold = warning (yellow)
|
// Low: below low stock threshold = warning (yellow)
|
||||||
@@ -37,13 +47,15 @@ function getStockStatus(
|
|||||||
function getDayStockStatus(
|
function getDayStockStatus(
|
||||||
dayMeds: Array<{ medName: string }>,
|
dayMeds: Array<{ medName: string }>,
|
||||||
coverageByMed: Record<string, Coverage>,
|
coverageByMed: Record<string, Coverage>,
|
||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
||||||
|
meds: Array<{ name: string; genericName?: string | null; packageType?: string }>
|
||||||
): string {
|
): string {
|
||||||
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
||||||
for (const item of dayMeds) {
|
for (const item of dayMeds) {
|
||||||
const cov = coverageByMed[item.medName];
|
const cov = coverageByMed[item.medName];
|
||||||
if (!cov) continue;
|
if (!cov) continue;
|
||||||
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
|
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings, med?.packageType);
|
||||||
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
|
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
|
||||||
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
|
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
|
||||||
}
|
}
|
||||||
@@ -80,6 +92,87 @@ export function SchedulePage() {
|
|||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
|
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatNumber(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: (typeof meds)[number] | undefined,
|
||||||
|
total: number,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (med?.packageType === "liquid_container") {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${formatNumber(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatNumber(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (med?.packageType === "tube") {
|
||||||
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
|
}
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
<article className="card schedule-full">
|
<article className="card schedule-full">
|
||||||
@@ -133,7 +226,7 @@ export function SchedulePage() {
|
|||||||
|
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings, meds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -185,7 +278,7 @@ export function SchedulePage() {
|
|||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
@@ -197,7 +290,7 @@ export function SchedulePage() {
|
|||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
@@ -341,8 +434,9 @@ export function SchedulePage() {
|
|||||||
const status = willBeOutOfStock
|
const status = willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
@@ -353,8 +447,10 @@ export function SchedulePage() {
|
|||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{formatTotalUsageLabel(med, item.total, item.doses)}</span>
|
||||||
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
|
{visibleStatus && (
|
||||||
|
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
@@ -368,7 +464,7 @@ export function SchedulePage() {
|
|||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
|||||||
@@ -663,6 +663,9 @@ export function SettingsPage() {
|
|||||||
settings.lowStockDays >= settings.highStockDays) && (
|
settings.lowStockDays >= settings.highStockDays) && (
|
||||||
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="hint-text" style={{ marginTop: "12px" }}>
|
||||||
|
ℹ️ {t("settings.stock.packageTypesNote")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Coverage, PackageType } from "../types";
|
import type { Coverage, Medication, PackageType } from "../types";
|
||||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ export function getReminderStatusData(
|
|||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
_allLowCoverage: Coverage[],
|
_allLowCoverage: Coverage[],
|
||||||
allCoverage: Coverage[],
|
allCoverage: Coverage[],
|
||||||
|
meds: Medication[],
|
||||||
lastAutoEmailSent: string | null,
|
lastAutoEmailSent: string | null,
|
||||||
_lastNotificationType: string | null,
|
_lastNotificationType: string | null,
|
||||||
_lastNotificationChannel: string | null,
|
_lastNotificationChannel: string | null,
|
||||||
@@ -73,8 +74,12 @@ export function getReminderStatusData(
|
|||||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||||
} {
|
} {
|
||||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||||
|
const medByName = new Map(meds.map((med) => [med.name || med.genericName || "", med] as const));
|
||||||
|
|
||||||
for (const c of allCoverage) {
|
for (const c of allCoverage) {
|
||||||
|
const med = medByName.get(c.name);
|
||||||
|
if (med?.packageType === "tube") continue;
|
||||||
|
|
||||||
if (c.medsLeft <= 0) {
|
if (c.medsLeft <= 0) {
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||||
continue;
|
continue;
|
||||||
@@ -83,8 +88,11 @@ export function getReminderStatusData(
|
|||||||
if (c.daysLeft === null) continue;
|
if (c.daysLeft === null) continue;
|
||||||
|
|
||||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
const isLiquid = med?.packageType === "liquid_container";
|
||||||
const isLow = c.daysLeft < lowStockDays;
|
const liquidLowDays = Math.max(1, Math.floor(reminderDaysBefore));
|
||||||
|
const liquidCriticalDays = Math.max(1, Math.ceil(liquidLowDays / 2));
|
||||||
|
const isCritical = isLiquid ? c.daysLeft <= liquidCriticalDays : c.daysLeft <= reminderDaysBefore;
|
||||||
|
const isLow = isLiquid ? c.daysLeft <= liquidLowDays : c.daysLeft < lowStockDays;
|
||||||
if (!isCritical && !isLow) continue;
|
if (!isCritical && !isLow) continue;
|
||||||
|
|
||||||
const existing = lowStockMap.get(c.name);
|
const existing = lowStockMap.get(c.name);
|
||||||
|
|||||||
+59
-21
@@ -104,7 +104,7 @@ body.modal-open {
|
|||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.5rem 3rem;
|
padding: 2.5rem 1.5rem 1.5rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +669,16 @@ body.modal-open {
|
|||||||
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.med-grid-wrapper.is-empty .med-group-active {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-empty-state {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 0.35rem 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.med-group-head {
|
.med-group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2660,6 +2670,11 @@ button.has-validation-error {
|
|||||||
grid-template-columns: minmax(140px, 1.5fr) 90px 70px 100px 100px 90px 90px;
|
grid-template-columns: minmax(140px, 1.5fr) 90px 70px 100px 100px 90px 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-8 .table-head,
|
||||||
|
.table-8 .table-row {
|
||||||
|
grid-template-columns: minmax(130px, 1.4fr) 90px 130px 70px 95px 95px 90px 95px;
|
||||||
|
}
|
||||||
|
|
||||||
.email-sent-status {
|
.email-sent-status {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
@@ -2842,7 +2857,7 @@ button.has-validation-error {
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.page {
|
.page {
|
||||||
padding: 0.75rem 0.4rem 2rem;
|
padding: 0.75rem 0.4rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -4674,55 +4689,78 @@ button.has-validation-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-schedules {
|
.med-detail-schedules {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: auto auto 1fr auto auto auto;
|
||||||
gap: 0.5rem;
|
gap: 0.45rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-item {
|
.med-schedule-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
column-gap: 0.75rem;
|
||||||
gap: 0.35rem 0.75rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-usage {
|
.med-schedule-usage {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-freq {
|
.med-schedule-freq {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
grid-column: 2;
|
||||||
|
|
||||||
.med-schedule-time {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-person {
|
.med-schedule-person {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
grid-column: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-time {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
grid-column: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-bell {
|
.med-schedule-bell {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 0.35rem;
|
grid-column: 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .med-schedule-bell {
|
[data-theme="light"] .med-schedule-bell {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.med-detail-schedules {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-usage,
|
||||||
|
.med-schedule-freq,
|
||||||
|
.med-schedule-person,
|
||||||
|
.med-schedule-time,
|
||||||
|
.med-schedule-bell {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
padding: 1rem 2rem 1.5rem;
|
padding: 1rem 2rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -644,9 +644,9 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
// Each intake should show "1 pill" (not "2 pills")
|
// Each intake should show "1" in usage (not "2")
|
||||||
usageElements.forEach((el) => {
|
rows.forEach((el) => {
|
||||||
expect(el.textContent).toContain("1");
|
expect(el.textContent).toContain("1");
|
||||||
expect(el.textContent).not.toMatch(/^2\b/);
|
expect(el.textContent).not.toMatch(/^2\b/);
|
||||||
});
|
});
|
||||||
@@ -662,10 +662,10 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
// Legacy: 1 pill * 2 people = "2 pills"
|
// Legacy: 1 pill * 2 people = "2 pills"
|
||||||
expect(usageElements.length).toBe(1);
|
expect(rows.length).toBe(1);
|
||||||
expect(usageElements[0].textContent).toContain("2");
|
expect(rows[0].textContent).toContain("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows correct usage for single person with per-intake takenBy", () => {
|
it("shows correct usage for single person with per-intake takenBy", () => {
|
||||||
@@ -678,11 +678,11 @@ describe("MedDetailModal intake schedule usage display", () => {
|
|||||||
};
|
};
|
||||||
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
|
||||||
|
|
||||||
const usageElements = document.querySelectorAll(".med-schedule-usage");
|
const rows = document.querySelectorAll(".med-schedule-row .med-schedule-usage");
|
||||||
expect(usageElements.length).toBe(1);
|
expect(rows.length).toBe(1);
|
||||||
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
|
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
|
||||||
expect(usageElements[0].textContent).toContain("2");
|
expect(rows[0].textContent).toContain("2");
|
||||||
expect(usageElements[0].textContent).toContain("1000");
|
expect(rows[0].textContent).toContain("1000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ describe("MobileEditModal", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const amountInput = screen.getByLabelText("form.packageAmount") as HTMLInputElement;
|
const amountInput = screen.getByLabelText("form.packageAmountPerBottle") as HTMLInputElement;
|
||||||
expect(amountInput).toBeInTheDocument();
|
expect(amountInput).toBeInTheDocument();
|
||||||
expect(amountInput.tagName).toBe("INPUT");
|
expect(amountInput.tagName).toBe("INPUT");
|
||||||
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
expect(amountInput).toHaveAttribute("inputmode", "decimal");
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ vi.mock("../../utils/formatters", () => ({
|
|||||||
getSystemLocale: () => "en-US",
|
getSystemLocale: () => "en-US",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../utils/schedule", () => ({
|
vi.mock("../../utils/schedule", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../utils/schedule")>("../../utils/schedule");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
|
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
|
||||||
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
|
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
|
||||||
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
|
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
|
||||||
isDoseDismissed: vi.fn(() => false),
|
isDoseDismissed: vi.fn(() => false),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const meds: Medication[] = [
|
const meds: Medication[] = [
|
||||||
{
|
{
|
||||||
@@ -464,7 +468,7 @@ describe("useAppContext", () => {
|
|||||||
all: [
|
all: [
|
||||||
{
|
{
|
||||||
name: "Aspirin",
|
name: "Aspirin",
|
||||||
daysLeft: 2,
|
daysLeft: 8,
|
||||||
medsLeft: 5,
|
medsLeft: 5,
|
||||||
depletionTime: Date.now() + 100000,
|
depletionTime: Date.now() + 100000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null },
|
{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null },
|
||||||
{ name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null },
|
{ name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null },
|
||||||
],
|
],
|
||||||
|
[],
|
||||||
"2026-01-01T10:00:00.000Z",
|
"2026-01-01T10:00:00.000Z",
|
||||||
"intake",
|
"intake",
|
||||||
"email",
|
"email",
|
||||||
@@ -270,6 +271,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
30,
|
30,
|
||||||
[],
|
[],
|
||||||
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
[{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
||||||
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -288,6 +290,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
30,
|
30,
|
||||||
[],
|
[],
|
||||||
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
[{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }],
|
||||||
|
[],
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ describe("getMedTotal", () => {
|
|||||||
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
|
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
|
||||||
expect(getMedTotal(med)).toBe(80);
|
expect(getMedTotal(med)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates tube/liquid totals from amount fields, not blister math", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 600,
|
||||||
|
looseTablets: 600,
|
||||||
|
stockAdjustment: 4,
|
||||||
|
};
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 3,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getMedTotal(tube)).toBe(604);
|
||||||
|
expect(getMedTotal(liquid)).toBe(450);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPackageSize", () => {
|
describe("getPackageSize", () => {
|
||||||
@@ -148,6 +171,28 @@ describe("getPackageSize", () => {
|
|||||||
// Should use looseTablets only, ignore stockAdjustment and blister math
|
// Should use looseTablets only, ignore stockAdjustment and blister math
|
||||||
expect(getPackageSize(med)).toBe(80);
|
expect(getPackageSize(med)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns totalPills for tube/liquid container package size", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 600,
|
||||||
|
looseTablets: 600,
|
||||||
|
};
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 3,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPackageSize(tube)).toBe(600);
|
||||||
|
expect(getPackageSize(liquid)).toBe(450);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FIELD_LIMITS", () => {
|
describe("FIELD_LIMITS", () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
calculateCoverage,
|
calculateCoverage,
|
||||||
computeMissedPastDoseIds,
|
computeMissedPastDoseIds,
|
||||||
expandDoseIds,
|
expandDoseIds,
|
||||||
getNextReminderForMed,
|
|
||||||
getReminderStatusText,
|
getReminderStatusText,
|
||||||
getStockStatus,
|
getStockStatus,
|
||||||
isDoseDismissed,
|
isDoseDismissed,
|
||||||
@@ -1202,6 +1201,80 @@ describe("getStockStatus", () => {
|
|||||||
expect(result.level).toBe("critical");
|
expect(result.level).toBe("critical");
|
||||||
expect(result.className).toBe("danger");
|
expect(result.className).toBe("danger");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns normal (no stock reminder semantics) for tube packageType regardless of stock thresholds", () => {
|
||||||
|
// Tubes have no stock reminder semantics: thresholds (low, critical, high) do not apply.
|
||||||
|
// However, if truly empty or exhausted, out-of-stock is still returned.
|
||||||
|
const resultWithMeds = getStockStatus(100, 50, thresholds, "tube");
|
||||||
|
expect(resultWithMeds.level).toBe("normal");
|
||||||
|
expect(resultWithMeds.className).toBe("success");
|
||||||
|
expect(resultWithMeds.label).toBe("status.noSchedule");
|
||||||
|
|
||||||
|
// Even with low days remaining (would be critical for non-tube)
|
||||||
|
const resultLow = getStockStatus(2, 50, thresholds, "tube");
|
||||||
|
expect(resultLow.level).toBe("normal");
|
||||||
|
expect(resultLow.className).toBe("success");
|
||||||
|
|
||||||
|
// Exhausted/empty tubes still show as out-of-stock
|
||||||
|
const resultEmpty = getStockStatus(0, 0, thresholds, "tube");
|
||||||
|
expect(resultEmpty.level).toBe("out-of-stock");
|
||||||
|
expect(resultEmpty.className).toBe("danger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies liquid_container thresholds: low=critical(threshold), critical=ceil(critical/2)", () => {
|
||||||
|
// For liquid_container, baseline is criticalStockDays (7)
|
||||||
|
// low = 7, critical = ceil(7/2) = 4
|
||||||
|
const thresholdsLiquid: StockThresholds = {
|
||||||
|
lowStockDays: 30,
|
||||||
|
criticalStockDays: 7,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// daysLeft = 8 (above low threshold of 7)
|
||||||
|
const resultNormal = getStockStatus(8, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultNormal.level).toBe("normal");
|
||||||
|
expect(resultNormal.className).toBe("success");
|
||||||
|
|
||||||
|
// daysLeft = 7 (at low threshold, below normal)
|
||||||
|
const resultLow = getStockStatus(7, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultLow.level).toBe("low");
|
||||||
|
expect(resultLow.className).toBe("warning");
|
||||||
|
|
||||||
|
// daysLeft = 4 (at critical threshold)
|
||||||
|
const resultCritical = getStockStatus(4, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultCritical.level).toBe("critical");
|
||||||
|
expect(resultCritical.className).toBe("danger");
|
||||||
|
|
||||||
|
// daysLeft = 2 (below critical threshold)
|
||||||
|
const resultVeryCritical = getStockStatus(2, 100, thresholdsLiquid, "liquid_container");
|
||||||
|
expect(resultVeryCritical.level).toBe("critical");
|
||||||
|
expect(resultVeryCritical.className).toBe("danger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles liquid_container with boundary baseline (criticalStockDays=1)", () => {
|
||||||
|
// Boundary case: criticalStockDays=1, so low=1, critical=ceil(1/2)=1
|
||||||
|
const boundaryThresholds: StockThresholds = {
|
||||||
|
lowStockDays: 30,
|
||||||
|
criticalStockDays: 1,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// daysLeft = 2 (above low threshold)
|
||||||
|
const resultNormal = getStockStatus(2, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultNormal.level).toBe("normal");
|
||||||
|
|
||||||
|
// daysLeft = 1 (at low and critical thresholds)
|
||||||
|
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultCritical.level).toBe("critical");
|
||||||
|
|
||||||
|
// daysLeft = 0 (out of stock)
|
||||||
|
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
|
||||||
|
expect(resultEmpty.level).toBe("out-of-stock");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getNextReminderForMed", () => {
|
describe("getNextReminderForMed", () => {
|
||||||
@@ -1213,53 +1286,7 @@ describe("getNextReminderForMed", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns "—" when no depletion time', () => {
|
const mockT = (key: string, options?: Record<string, number>) => {
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 100,
|
|
||||||
daysLeft: null,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: null,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(getNextReminderForMed(med, 7, "en")).toBe("—");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "Due now" when reminder time is past', () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 5,
|
|
||||||
daysLeft: 3,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: now + 3 * 86400000,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reminder 7 days before = already past
|
|
||||||
expect(getNextReminderForMed(med, 7, "en")).toBe("Due now");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns formatted date for future reminder", () => {
|
|
||||||
const now = Date.now();
|
|
||||||
const med: Coverage = {
|
|
||||||
name: "Test",
|
|
||||||
medsLeft: 100,
|
|
||||||
daysLeft: 30,
|
|
||||||
depletionDate: null,
|
|
||||||
depletionTime: now + 30 * 86400000,
|
|
||||||
nextDose: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = getNextReminderForMed(med, 7, "en-US");
|
|
||||||
expect(result).not.toBe("—");
|
|
||||||
expect(result).not.toBe("Due now");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getReminderStatusText", () => {
|
|
||||||
const mockT = (key: string, options?: Record<string, unknown>) => {
|
|
||||||
if (options?.count) return `${key} (${options.count})`;
|
if (options?.count) return `${key} (${options.count})`;
|
||||||
if (options?.days) return `${key} (${options.days})`;
|
if (options?.days) return `${key} (${options.days})`;
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Core Types for MedAssist
|
// Core Types for MedAssist
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export type PackageType = "blister" | "bottle";
|
export type PackageType = "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
|
|
||||||
// Common medication dose units
|
// Common medication dose units
|
||||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
|
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
|
||||||
@@ -20,6 +20,8 @@ export type Blister = {
|
|||||||
start: string;
|
start: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IntakeUnit = "ml" | "tsp" | "tbsp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intake with per-intake takenBy support.
|
* Intake with per-intake takenBy support.
|
||||||
* Extends Blister with per-intake user assignment.
|
* Extends Blister with per-intake user assignment.
|
||||||
@@ -28,6 +30,7 @@ export type Intake = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
|
intakeUnit?: IntakeUnit | null;
|
||||||
takenBy: string | null; // Per-intake user assignment (single person or null)
|
takenBy: string | null; // Per-intake user assignment (single person or null)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -102,6 +105,7 @@ export type FormIntake = {
|
|||||||
every: string;
|
every: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
intakeUnit?: IntakeUnit;
|
||||||
takenBy: string; // Single person or empty string (empty = null for everyone)
|
takenBy: string; // Single person or empty string (empty = null for everyone)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -167,6 +171,7 @@ export type ScheduleEvent = {
|
|||||||
timeStr: string;
|
timeStr: string;
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: IntakeUnit | null;
|
||||||
when: number;
|
when: number;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
@@ -248,13 +253,16 @@ export function getMedDisplayName(med: { name: string; genericName?: string | nu
|
|||||||
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
||||||
stockAdjustment?: number;
|
stockAdjustment?: number;
|
||||||
packageType?: PackageType;
|
packageType?: PackageType;
|
||||||
|
totalPills?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Calculate total pills including stockAdjustment */
|
/** Calculate total pills including stockAdjustment */
|
||||||
export function getMedTotal(med: MedLike): number {
|
export function getMedTotal(med: MedLike): number {
|
||||||
// For bottle type, looseTablets IS the current stock
|
// Amount-based package types store their current base stock directly
|
||||||
if (med.packageType === "bottle") {
|
// in totalPills (fallback looseTablets for legacy rows).
|
||||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
|
const baseStock = med.totalPills ?? med.looseTablets;
|
||||||
|
return baseStock + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
@@ -262,9 +270,9 @@ export function getMedTotal(med: MedLike): number {
|
|||||||
|
|
||||||
/** Get the base package size (without stockAdjustment) */
|
/** Get the base package size (without stockAdjustment) */
|
||||||
export function getPackageSize(med: MedLike): number {
|
export function getPackageSize(med: MedLike): number {
|
||||||
// For bottle type, looseTablets IS the current stock
|
// Amount-based package types use totalPills as base capacity
|
||||||
if (med.packageType === "bottle") {
|
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
||||||
return med.looseTablets;
|
return med.totalPills ?? med.looseTablets;
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||||
|
|||||||
@@ -2,9 +2,31 @@
|
|||||||
// Schedule Building and Coverage Calculations
|
// Schedule Building and Coverage Calculations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
import type {
|
||||||
|
Blister,
|
||||||
|
Coverage,
|
||||||
|
Intake,
|
||||||
|
Medication,
|
||||||
|
PackageType,
|
||||||
|
ScheduleEvent,
|
||||||
|
StockStatus,
|
||||||
|
StockThresholds,
|
||||||
|
} from "../types";
|
||||||
import { getMedDisplayName, getMedTotal } from "../types";
|
import { getMedDisplayName, getMedTotal } from "../types";
|
||||||
|
|
||||||
|
function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
|
if (med.packageType === "tube") return 0;
|
||||||
|
|
||||||
|
const isLiquidStock = med.packageType === "liquid_container" || med.medicationForm === "liquid";
|
||||||
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +40,7 @@ function getIntakesForMed(med: Medication): Intake[] {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // Legacy format has no per-intake takenBy
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -66,6 +89,7 @@ export function buildSchedulePreview(
|
|||||||
medName: getMedDisplayName(med),
|
medName: getMedDisplayName(med),
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
|
intakeUnit: intake.intakeUnit ?? null,
|
||||||
when: whenMs,
|
when: whenMs,
|
||||||
isPast,
|
isPast,
|
||||||
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
|
||||||
@@ -124,9 +148,11 @@ export function calculateCoverage(
|
|||||||
// one person's dose — do NOT multiply by personCount again.
|
// one person's dose — do NOT multiply by personCount again.
|
||||||
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
||||||
let dailyRate = 0;
|
let dailyRate = 0;
|
||||||
blisters.forEach((s, idx) => {
|
blisters.forEach((_s, idx) => {
|
||||||
const baseRate = s.every > 0 ? s.usage / s.every : 0;
|
|
||||||
const intake = intakes[idx];
|
const intake = intakes[idx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
|
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||||
if (intake?.takenBy) {
|
if (intake?.takenBy) {
|
||||||
// Per-intake takenBy: this intake is for exactly 1 person
|
// Per-intake takenBy: this intake is for exactly 1 person
|
||||||
dailyRate += baseRate;
|
dailyRate += baseRate;
|
||||||
@@ -149,6 +175,9 @@ export function calculateCoverage(
|
|||||||
blisters.forEach((s, blisterIdx) => {
|
blisters.forEach((s, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const blisterStart = new Date(s.start).getTime();
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
|
|
||||||
// After a stock correction, start counting consumption from the NEXT
|
// After a stock correction, start counting consumption from the NEXT
|
||||||
// scheduled dose on this blister's grid, because the user's pill count
|
// scheduled dose on this blister's grid, because the user's pill count
|
||||||
@@ -166,7 +195,6 @@ export function calculateCoverage(
|
|||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart)) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
|
|
||||||
// For per-intake takenBy, only count for that person
|
// For per-intake takenBy, only count for that person
|
||||||
@@ -180,7 +208,7 @@ export function calculateCoverage(
|
|||||||
|
|
||||||
if (effectiveStart <= now) {
|
if (effectiveStart <= now) {
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||||
|
|
||||||
// Date-only timestamp of the last auto-consumed dose
|
// Date-only timestamp of the last auto-consumed dose
|
||||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
@@ -212,7 +240,7 @@ export function calculateCoverage(
|
|||||||
const bIdx = parseInt(parts[1], 10);
|
const bIdx = parseInt(parts[1], 10);
|
||||||
const timestamp = parseInt(parts[2], 10);
|
const timestamp = parseInt(parts[2], 10);
|
||||||
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
earlyTakenConsumed += s.usage;
|
earlyTakenConsumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +260,9 @@ export function calculateCoverage(
|
|||||||
const blisterIdx = parseInt(parts[1], 10);
|
const blisterIdx = parseInt(parts[1], 10);
|
||||||
const doseTimestamp = parseInt(parts[2], 10);
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
if (medId === m.id && blisters[blisterIdx]) {
|
if (medId === m.id && blisters[blisterIdx]) {
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
if (!intake) return;
|
||||||
|
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||||
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
||||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
||||||
const blisterStartDateOnly = new Date(
|
const blisterStartDateOnly = new Date(
|
||||||
@@ -251,7 +282,7 @@ export function calculateCoverage(
|
|||||||
doseTimestamp >= blisterStartDateOnly &&
|
doseTimestamp >= blisterStartDateOnly &&
|
||||||
afterCorrectionOrNoCorrectionMs
|
afterCorrectionOrNoCorrectionMs
|
||||||
) {
|
) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,20 +323,47 @@ export function calculateCoverage(
|
|||||||
return { low, all: coverage };
|
return { low, all: coverage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLiquidDerivedThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||||
|
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
return { lowDays, criticalDays };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get stock status based on days left and thresholds
|
* Get stock status based on days left and thresholds
|
||||||
*/
|
*/
|
||||||
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
export function getStockStatus(
|
||||||
|
daysLeft: number | null,
|
||||||
|
medsLeft: number,
|
||||||
|
thresholds: StockThresholds,
|
||||||
|
packageType?: PackageType
|
||||||
|
): StockStatus {
|
||||||
// Out of stock or completely depleted = danger (red)
|
// Out of stock or completely depleted = danger (red)
|
||||||
if (medsLeft <= 0 || daysLeft === 0) {
|
if (medsLeft <= 0 || daysLeft === 0) {
|
||||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tube has no stock reminder semantics.
|
||||||
|
if (packageType === "tube") {
|
||||||
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
|
}
|
||||||
|
|
||||||
// No schedule, but has stock = normal
|
// No schedule, but has stock = normal
|
||||||
if (daysLeft === null) {
|
if (daysLeft === null) {
|
||||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packageType === "liquid_container") {
|
||||||
|
const liquidThresholds = getLiquidDerivedThresholds(thresholds.criticalStockDays);
|
||||||
|
if (daysLeft <= liquidThresholds.criticalDays) {
|
||||||
|
return { level: "critical", className: "danger", label: "status.criticalStock" };
|
||||||
|
}
|
||||||
|
if (daysLeft <= liquidThresholds.lowDays) {
|
||||||
|
return { level: "low", className: "warning", label: "status.lowStock" };
|
||||||
|
}
|
||||||
|
return { level: "normal", className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
|
|
||||||
// High stock
|
// High stock
|
||||||
if (daysLeft > thresholds.highStockDays) {
|
if (daysLeft > thresholds.highStockDays) {
|
||||||
return { level: "high", className: "high", label: "status.highStock" };
|
return { level: "high", className: "high", label: "status.highStock" };
|
||||||
|
|||||||
Reference in New Issue
Block a user