Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8279bd521 | |||
| 36d50c0736 | |||
| 5b6c6abb69 | |||
| 30c97e2f0d | |||
| de1a508e52 | |||
| 54d26e0241 | |||
| ac47fc001d | |||
| 4936929849 | |||
| 6672fb78c9 | |||
| b349e26833 | |||
| 56d244aa61 | |||
| 1a348c62f5 | |||
| 067a8c166b | |||
| 8fdd79ff33 | |||
| cd8263e607 | |||
| e6a097d81d |
@@ -163,6 +163,7 @@ When code changes (features or bug fixes) are complete:
|
|||||||
```
|
```
|
||||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||||
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||||
|
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
|
||||||
- The `--project` flag links the PR to the Project board.
|
- The `--project` flag links the PR to the Project board.
|
||||||
4. **Present the PR URL to the user and wait for confirmation.**
|
4. **Present the PR URL to the user and wait for confirmation.**
|
||||||
|
|
||||||
@@ -451,6 +452,7 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
|||||||
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
||||||
|
|
||||||
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
||||||
|
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
|
||||||
|
|
||||||
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+5
-2
@@ -82,5 +82,8 @@ Thumbs.db
|
|||||||
.claude/
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku/
|
||||||
plan
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
plan/
|
||||||
|
.copilot-tracking
|
||||||
@@ -120,10 +120,10 @@ Share your medication schedule with others via a public link.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
- Manual stock correction supports partial blisters and loose pills
|
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with pack or loose pill options
|
||||||
@@ -141,7 +141,7 @@ Share your medication schedule with others via a public link.
|
|||||||
- Intake reminders via push notifications
|
- Intake reminders via push notifications
|
||||||
|
|
||||||
### Trip Planner
|
### Trip Planner
|
||||||
- Calculate how many pills you need for a trip or date range
|
- Calculate medication demand for a trip or date range with package-aware units
|
||||||
- Plan ahead for vacations, business trips, or hospital stays
|
- Plan ahead for vacations, business trips, or hospital stays
|
||||||
- Send demand reports via email or push notification
|
- Send demand reports via email or push notification
|
||||||
|
|
||||||
|
|||||||
Generated
+11
-11
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.17.1",
|
"version": "1.18.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.17.1",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.8.1",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
@@ -2625,9 +2625,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.2",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -4156,9 +4156,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.7.4",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.1.tgz",
|
||||||
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
|
"integrity": "sha512-y0kicFvvn7CYWoPOVLOcvn4YyKQz03DIY7UxmyOy21/J8eXm09R+tmb+tVDBW5h+pja30cHI5dqUcSlvY86V2A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4180,7 +4180,7 @@
|
|||||||
"fast-json-stringify": "^6.0.0",
|
"fast-json-stringify": "^6.0.0",
|
||||||
"find-my-way": "^9.0.0",
|
"find-my-way": "^9.0.0",
|
||||||
"light-my-request": "^6.0.0",
|
"light-my-request": "^6.0.0",
|
||||||
"pino": "^10.1.0",
|
"pino": "^9.14.0 || ^10.1.0",
|
||||||
"process-warning": "^5.0.0",
|
"process-warning": "^5.0.0",
|
||||||
"rfdc": "^1.3.1",
|
"rfdc": "^1.3.1",
|
||||||
"secure-json-parse": "^4.0.0",
|
"secure-json-parse": "^4.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.8.1",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
@@ -39,7 +40,7 @@ const inventorySchema = z.object({
|
|||||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
packageType: z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister"),
|
packageType: z.enum(PACKAGE_TYPES).default("blister"),
|
||||||
packageAmountValue: z.number().int().min(0).default(0),
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
});
|
});
|
||||||
@@ -319,7 +320,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
totalPills: med.totalPills ?? null,
|
totalPills: med.totalPills ?? null,
|
||||||
looseTablets: med.looseTablets ?? 0,
|
looseTablets: med.looseTablets ?? 0,
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: normalizePackageType(med.packageType),
|
||||||
packageAmountValue: med.packageAmountValue ?? 0,
|
packageAmountValue: med.packageAmountValue ?? 0,
|
||||||
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
},
|
},
|
||||||
@@ -595,7 +596,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: med.medicationForm ?? "tablet",
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
pillForm: med.pillForm || null,
|
pillForm: med.pillForm || null,
|
||||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: med.inventory.packageType ?? "blister",
|
packageType: normalizePackageType(med.inventory.packageType),
|
||||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||||
packCount: med.inventory.packCount,
|
packCount: med.inventory.packCount,
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
streamToBuffer,
|
streamToBuffer,
|
||||||
writeOptimizedImageSet,
|
writeOptimizedImageSet,
|
||||||
} from "../utils/image-upload.js";
|
} from "../utils/image-upload.js";
|
||||||
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
PACKAGE_TYPES,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
import {
|
import {
|
||||||
type Intake,
|
type Intake,
|
||||||
normalizeIntakeUsageForStock,
|
normalizeIntakeUsageForStock,
|
||||||
@@ -75,7 +82,7 @@ const blisterSchema = z.object({
|
|||||||
start: z.string().datetime({ local: true }),
|
start: z.string().datetime({ local: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const packageTypeSchema = z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister");
|
const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
|
||||||
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
||||||
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||||
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
||||||
@@ -163,7 +170,7 @@ const medicationSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.medicationForm === "topical") {
|
if (data.medicationForm === "topical") {
|
||||||
return data.packageType === "tube";
|
return isTubePackageType(data.packageType);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -175,7 +182,7 @@ const medicationSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.medicationForm === "liquid") {
|
if (data.medicationForm === "liquid") {
|
||||||
return data.packageType === "liquid_container";
|
return isLiquidContainerPackageType(data.packageType);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -187,7 +194,7 @@ const medicationSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
||||||
return data.packageType !== "tube" && data.packageType !== "liquid_container";
|
return !isTubePackageType(data.packageType) && !isLiquidContainerPackageType(data.packageType);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -294,7 +301,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: row.medicationForm ?? "tablet",
|
medicationForm: row.medicationForm ?? "tablet",
|
||||||
pillForm: row.pillForm ?? null,
|
pillForm: row.pillForm ?? null,
|
||||||
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: row.packageType ?? "blister",
|
packageType: normalizePackageType(row.packageType),
|
||||||
packCount: row.packCount ?? 1,
|
packCount: row.packCount ?? 1,
|
||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
@@ -412,7 +419,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: medicationForm ?? "tablet",
|
medicationForm: medicationForm ?? "tablet",
|
||||||
pillForm: normalizedPillForm,
|
pillForm: normalizedPillForm,
|
||||||
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: packageType ?? "blister",
|
packageType: normalizePackageType(packageType),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
@@ -448,7 +455,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: inserted.medicationForm ?? "tablet",
|
medicationForm: inserted.medicationForm ?? "tablet",
|
||||||
pillForm: inserted.pillForm ?? null,
|
pillForm: inserted.pillForm ?? null,
|
||||||
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: inserted.packageType ?? "blister",
|
packageType: normalizePackageType(inserted.packageType),
|
||||||
packCount: inserted.packCount,
|
packCount: inserted.packCount,
|
||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
@@ -583,7 +590,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: medicationForm ?? "tablet",
|
medicationForm: medicationForm ?? "tablet",
|
||||||
pillForm: normalizedPillForm,
|
pillForm: normalizedPillForm,
|
||||||
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: packageType ?? "blister",
|
packageType: normalizePackageType(packageType),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
@@ -743,7 +750,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationForm: result[0].medicationForm ?? "tablet",
|
medicationForm: result[0].medicationForm ?? "tablet",
|
||||||
pillForm: result[0].pillForm ?? null,
|
pillForm: result[0].pillForm ?? null,
|
||||||
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: result[0].packageType ?? "blister",
|
packageType: normalizePackageType(result[0].packageType),
|
||||||
packCount: result[0].packCount,
|
packCount: result[0].packCount,
|
||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
@@ -901,8 +908,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const packageType = existing.packageType ?? "blister";
|
const packageType = normalizePackageType(existing.packageType);
|
||||||
const allowsAmountBaseUpdate = packageType === "tube" || packageType === "liquid_container";
|
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
if (allowsAmountBaseUpdate) {
|
if (allowsAmountBaseUpdate) {
|
||||||
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
||||||
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
||||||
@@ -1097,14 +1104,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
const looseTablets = row.looseTablets ?? 0;
|
const looseTablets = row.looseTablets ?? 0;
|
||||||
const stockAdjustment = row.stockAdjustment ?? 0;
|
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||||
const packageType = row.packageType ?? "blister";
|
const packageType = normalizePackageType(row.packageType);
|
||||||
|
|
||||||
// For bottle type, looseTablets IS the current stock (no blister math)
|
// For bottle type, looseTablets IS the current stock (no blister math)
|
||||||
const isTopical = medForm === "topical" || packageType === "tube";
|
const isTopical = medForm === "topical" || isTubePackageType(packageType);
|
||||||
const originalTotalPills =
|
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||||
packageType === "bottle" || packageType === "liquid_container"
|
? looseTablets + stockAdjustment
|
||||||
? looseTablets + stockAdjustment
|
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
|
||||||
|
|
||||||
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
@@ -1232,7 +1238,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
let fullBlisters: number;
|
let fullBlisters: number;
|
||||||
let loosePills: number;
|
let loosePills: number;
|
||||||
|
|
||||||
if (packageType === "bottle" || packageType === "tube" || packageType === "liquid_container") {
|
if (isAmountBasedPackageType(packageType)) {
|
||||||
// Bottle type: no blisters, everything is loose pills
|
// Bottle type: no blisters, everything is loose pills
|
||||||
fullBlisters = 0;
|
fullBlisters = 0;
|
||||||
loosePills = availableAfterPeriod;
|
loosePills = availableAfterPeriod;
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
|||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
getPlannerUnitKind,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||||
|
|
||||||
// Escape HTML to prevent XSS in email templates
|
// Escape HTML to prevent XSS in email templates
|
||||||
@@ -80,12 +86,13 @@ type PlannerRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isContainerPackage(packageType?: string): boolean {
|
function isContainerPackage(packageType?: string): boolean {
|
||||||
return packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
return isAmountBasedPackageType(packageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
|
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
|
||||||
if (packageType === "tube") return tr.common.units;
|
const unitKind = getPlannerUnitKind(packageType);
|
||||||
if (packageType === "liquid_container") return tr.common.ml;
|
if (unitKind === "units") return tr.common.units;
|
||||||
|
if (unitKind === "ml") return tr.common.ml;
|
||||||
return tr.common.pills;
|
return tr.common.pills;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,13 +488,13 @@ ${getFooterPlain(language)}`;
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||||
const activeMedicationByName = new Map(
|
const activeMedicationByName = new Map(
|
||||||
activeMeds
|
activeMeds
|
||||||
.map((med) => [med.name || med.genericName || "", med.packageType ?? "blister"] as const)
|
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
||||||
.filter(([name]) => name.length > 0)
|
.filter(([name]) => name.length > 0)
|
||||||
);
|
);
|
||||||
const filteredLowStock = lowStock.filter((item) => {
|
const filteredLowStock = lowStock.filter((item) => {
|
||||||
const packageType = activeMedicationByName.get(item.name);
|
const packageType = activeMedicationByName.get(item.name);
|
||||||
if (!packageType) return false;
|
if (!packageType) return false;
|
||||||
if (packageType === "tube") return false;
|
if (isTubePackageType(packageType)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (filteredLowStock.length === 0) {
|
if (filteredLowStock.length === 0) {
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
const settings = await getOrCreateUserSettings(userId);
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
|
const reminderHour = envInt("REMINDER_HOUR", 6);
|
||||||
|
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
// User notification settings (from DB)
|
// User notification settings (from DB)
|
||||||
@@ -376,6 +378,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||||
// Server settings (from .env, read-only)
|
// Server settings (from .env, read-only)
|
||||||
|
reminderHour,
|
||||||
|
reminderMinutesBefore,
|
||||||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
import {
|
import {
|
||||||
getAllTakenByForMedication,
|
getAllTakenByForMedication,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -119,12 +120,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills =
|
const totalPills = isAmountBasedPackageType(med.packageType)
|
||||||
(med.packageType ?? "blister") === "bottle" ||
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
(med.packageType ?? "blister") === "tube" ||
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
(med.packageType ?? "blister") === "liquid_container"
|
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
return {
|
return {
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
@@ -133,7 +131,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
imageUrl: med.imageUrl,
|
imageUrl: med.imageUrl,
|
||||||
totalPills,
|
totalPills,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: normalizePackageType(med.packageType),
|
||||||
packCount: med.packCount,
|
packCount: med.packCount,
|
||||||
blistersPerPack: med.blistersPerPack,
|
blistersPerPack: med.blistersPerPack,
|
||||||
looseTablets: med.looseTablets,
|
looseTablets: med.looseTablets,
|
||||||
|
|||||||
@@ -50,6 +50,36 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
const intakeDate = intake.intakeTime;
|
const intakeDate = intake.intakeTime;
|
||||||
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
@@ -166,7 +196,7 @@ async function sendIntakeReminderEmail(
|
|||||||
repeatIntervalMinutes?: number,
|
repeatIntervalMinutes?: number,
|
||||||
currentCount?: number,
|
currentCount?: number,
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const smtpUser = process.env.SMTP_USER;
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||||
@@ -310,7 +340,7 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `💊 ${subject}`,
|
subject: `💊 ${subject}`,
|
||||||
@@ -318,7 +348,16 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
return { success: false, error: deliveryError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: mailResult.messageId,
|
||||||
|
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
@@ -380,17 +419,26 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Build medication entries that have at least one reminder-enabled intake.
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
// Intake-level reminders are the single source of truth.
|
||||||
|
const reminderEntries = rows
|
||||||
|
.map((med) => {
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
|
||||||
|
return { med, intakes, intakesWithReminders };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.intakesWithReminders.length > 0);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (reminderEntries.length === 0) {
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] User ${settings.userId}: Found ${reminderEntries.length} medications with reminders`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
@@ -407,13 +455,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const med of medsWithReminders) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||||
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakesJson,
|
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
|
||||||
med.intakeRemindersEnabled ?? false
|
|
||||||
);
|
|
||||||
// Medication-level takenBy (for fallback/display purposes)
|
// Medication-level takenBy (for fallback/display purposes)
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
const medDisplayName = med.name || med.genericName || "";
|
const medDisplayName = med.name || med.genericName || "";
|
||||||
@@ -422,15 +464,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
|
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
|
||||||
const intakesWithReminders = intakes.filter((intake, idx) => {
|
|
||||||
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
|
|
||||||
if (!hasReminder) {
|
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
|
|
||||||
}
|
|
||||||
return hasReminder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||||
@@ -670,7 +703,9 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
logger.info(
|
||||||
|
`[IntakeReminder] User ${settings.userId}: Email sent successfully (to: ${settings.notificationEmail}, messageId: ${result.messageId}, smtp: ${result.smtpResponse})`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
|
|||||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
import {
|
import {
|
||||||
type Blister,
|
type Blister,
|
||||||
@@ -268,9 +274,10 @@ async function getMedicationsNeedingReminder(
|
|||||||
const msPerDay = 86_400_000;
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const packageType = normalizePackageType(row.packageType);
|
||||||
// Tube stock reminders are intentionally disabled:
|
// Tube stock reminders are intentionally disabled:
|
||||||
// topical usage in grams cannot be mapped reliably to schedule events.
|
// topical usage in grams cannot be mapped reliably to schedule events.
|
||||||
if ((row.packageType ?? "blister") === "tube") continue;
|
if (isTubePackageType(packageType)) continue;
|
||||||
|
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
@@ -283,10 +290,9 @@ async function getMedicationsNeedingReminder(
|
|||||||
start: i.start,
|
start: i.start,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const originalTotalPills =
|
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||||
(row.packageType ?? "blister") === "bottle"
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
|
||||||
|
|
||||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
@@ -393,7 +399,7 @@ async function getMedicationsNeedingReminder(
|
|||||||
|
|
||||||
if (daysLeft === null) continue;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
const isLiquid = (row.packageType ?? "blister") === "liquid_container";
|
const isLiquid = isLiquidContainerPackageType(packageType);
|
||||||
const { lowDays, criticalDays } = isLiquid
|
const { lowDays, criticalDays } = isLiquid
|
||||||
? getLiquidReminderThresholds(reminderDaysBefore)
|
? getLiquidReminderThresholds(reminderDaysBefore)
|
||||||
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||||
|
|
||||||
|
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||||
|
|
||||||
|
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
|
||||||
|
|
||||||
|
export function normalizePackageType(packageType?: string | null): PackageType {
|
||||||
|
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
|
||||||
|
return packageType as PackageType;
|
||||||
|
}
|
||||||
|
return "blister";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTubePackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "tube";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
if (normalized === "tube") return "units";
|
||||||
|
if (normalized === "liquid_container") return "ml";
|
||||||
|
return "pills";
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||||
|
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
|
||||||
|
|
||||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||||
export type Blister = { usage: number; every: number; start: string };
|
export type Blister = { usage: number; every: number; start: string };
|
||||||
@@ -36,9 +37,9 @@ export function normalizeIntakeUsageForStock(
|
|||||||
): number {
|
): number {
|
||||||
const usage = Number(intake.usage);
|
const usage = Number(intake.usage);
|
||||||
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
if (packageType === "tube") return 0;
|
if (isTubePackageType(packageType)) return 0;
|
||||||
|
|
||||||
const isLiquidStock = packageType === "liquid_container" || medicationForm === "liquid";
|
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
|
||||||
if (!isLiquidStock) return usage;
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
if (intake.intakeUnit === "tsp") return usage * 5;
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-2554
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show medication overview table with medications", async ({ page }) => {
|
test("should show medication overview table with medications", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show status chips in overview table", async ({ page }) => {
|
test("should show status chips in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Each medication row should have a status chip
|
// Each medication row should have a status chip
|
||||||
@@ -88,7 +88,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show stock information in overview", async ({ page }) => {
|
test("should show stock information in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
||||||
@@ -202,7 +202,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should open medication detail modal from overview table", async ({ page }) => {
|
test("should open medication detail modal from overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ export { expect };
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
function getAuthCookie(): string | null {
|
let cachedAuthCookie: string | null = null;
|
||||||
|
|
||||||
|
function readAuthCookieFromFile(): string | null {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
||||||
@@ -186,6 +188,49 @@ function getAuthCookie(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeaders: string[], name: string): string | null {
|
||||||
|
for (const header of setCookieHeaders) {
|
||||||
|
const [pair] = header.split(";");
|
||||||
|
if (!pair) continue;
|
||||||
|
const [cookieName, ...valueParts] = pair.split("=");
|
||||||
|
if (cookieName?.trim() !== name) continue;
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthCookieViaLogin(): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: TEST_USER.username,
|
||||||
|
password: TEST_USER.password,
|
||||||
|
rememberMe: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const getSetCookie = (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
const setCookieHeaders = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
||||||
|
const fallback = res.headers.get("set-cookie");
|
||||||
|
if (fallback) setCookieHeaders.push(fallback);
|
||||||
|
|
||||||
|
const accessToken = extractCookieValue(setCookieHeaders, "access_token");
|
||||||
|
if (accessToken) {
|
||||||
|
cachedAuthCookie = accessToken;
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthCookie(): string | null {
|
||||||
|
if (cachedAuthCookie) return cachedAuthCookie;
|
||||||
|
cachedAuthCookie = readAuthCookieFromFile();
|
||||||
|
return cachedAuthCookie;
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed medication response (subset of fields we care about) */
|
/** Typed medication response (subset of fields we care about) */
|
||||||
export interface TestMedication {
|
export interface TestMedication {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -214,12 +259,14 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string[];
|
takenBy?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
|
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
intakeRemindersEnabled?: boolean;
|
intakeRemindersEnabled?: boolean;
|
||||||
intakes?: {
|
intakes?: {
|
||||||
usage: number;
|
usage: number;
|
||||||
@@ -229,16 +276,30 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string | null;
|
takenBy?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}): Promise<TestMedication> {
|
}): Promise<TestMedication> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
const isBottle = data.packageType === "bottle";
|
const packageType = data.packageType ?? "blister";
|
||||||
|
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||||
|
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||||
|
if (packageType === "tube") {
|
||||||
|
defaultMedicationForm = "topical";
|
||||||
|
} else if (packageType === "liquid_container") {
|
||||||
|
defaultMedicationForm = "liquid";
|
||||||
|
}
|
||||||
|
const medicationForm = data.medicationForm ?? defaultMedicationForm;
|
||||||
|
const packageAmountValue =
|
||||||
|
data.packageAmountValue ??
|
||||||
|
(packageType === "tube" || packageType === "liquid_container" ? Math.max(1, data.totalPills ?? 30) : 0);
|
||||||
const body = {
|
const body = {
|
||||||
packageType: isBottle ? "bottle" : "blister",
|
packageType,
|
||||||
packCount: isBottle ? 1 : (data.packCount ?? 1),
|
medicationForm,
|
||||||
blistersPerPack: isBottle ? 1 : (data.blistersPerPack ?? 1),
|
packCount: packageType === "tube" ? 1 : (data.packCount ?? 1),
|
||||||
pillsPerBlister: isBottle ? 1 : (data.pillsPerBlister ?? 10),
|
blistersPerPack: isAmountBased ? 1 : (data.blistersPerPack ?? 1),
|
||||||
// For bottles: looseTablets IS the current stock. Default to totalPills if not specified.
|
pillsPerBlister: isAmountBased ? 1 : (data.pillsPerBlister ?? 10),
|
||||||
looseTablets: isBottle ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
// Amount-based packages use looseTablets as current stock.
|
||||||
totalPills: isBottle ? (data.totalPills ?? null) : null,
|
looseTablets: isAmountBased ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
||||||
|
totalPills: isAmountBased ? (data.totalPills ?? null) : null,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit: packageType === "tube" ? "g" : "ml",
|
||||||
intakes: [
|
intakes: [
|
||||||
{
|
{
|
||||||
usage: 1,
|
usage: 1,
|
||||||
@@ -261,6 +322,10 @@ export async function createMedicationViaAPI(data: {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
@@ -280,12 +345,16 @@ export async function createMedicationViaAPI(data: {
|
|||||||
* Includes retry for rate-limited responses.
|
* Includes retry for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
|
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
@@ -299,11 +368,15 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
|||||||
* Includes retry logic for rate-limited responses.
|
* Includes retry logic for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/medications`, {
|
const res = await fetch(`${API_BASE}/api/medications`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
@@ -316,6 +389,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (delRes.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (delRes.status === 429) {
|
if (delRes.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
continue;
|
continue;
|
||||||
@@ -332,7 +409,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
* Requires a medication with takenBy to exist first.
|
* Requires a medication with takenBy to exist first.
|
||||||
*/
|
*/
|
||||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/share`, {
|
const res = await fetch(`${API_BASE}/api/share`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -342,6 +419,10 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
body: JSON.stringify({ takenBy, scheduleDays }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
|||||||
opts: {
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
genericName?: string;
|
genericName?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
packs?: string;
|
packs?: string;
|
||||||
blistersPerPack?: string;
|
blistersPerPack?: string;
|
||||||
pillsPerBlister?: string;
|
pillsPerBlister?: string;
|
||||||
@@ -56,6 +56,18 @@ async function fillAndSaveMedication(
|
|||||||
if (opts.totalCapacity)
|
if (opts.totalCapacity)
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||||
|
} else if (opts.packageType === "tube") {
|
||||||
|
await packageTypeSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
|
} else if (opts.packageType === "liquid_container") {
|
||||||
|
await packageTypeSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Package amount|form\.packageAmount)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await packageTypeSelect.selectOption("blister");
|
await packageTypeSelect.selectOption("blister");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
@@ -83,7 +95,11 @@ async function fillAndSaveMedication(
|
|||||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = form.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
await row
|
||||||
|
.getByLabel(
|
||||||
|
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
||||||
|
)
|
||||||
|
.fill(intakes[i].usage);
|
||||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +210,26 @@ test.describe("Medication CRUD", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create a tube medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Tube Cream",
|
||||||
|
packageType: "tube",
|
||||||
|
totalCapacity: "50",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create a liquid-container medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Liquid Syrup",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
totalCapacity: "120",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should create medication with notes and expiry date", async ({ page }) => {
|
test("should create medication with notes and expiry date", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ test.describe("Medication Editing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should change package type between blister and bottle", async ({ page }) => {
|
test("should change package type across all supported profiles", async ({ page }) => {
|
||||||
createdMeds.push(
|
createdMeds.push(
|
||||||
await createMedicationViaAPI({
|
await createMedicationViaAPI({
|
||||||
name: "PackType Change Med",
|
name: "PackType Change Med",
|
||||||
@@ -357,15 +357,24 @@ test.describe("Medication Editing", () => {
|
|||||||
await packageSelect.selectOption("bottle");
|
await packageSelect.selectOption("bottle");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Fill bottle-specific fields
|
// Switch to tube
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
await packageSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
|
// Switch to liquid container and persist this final state
|
||||||
|
await packageSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
// Verify it's still a bottle after reload
|
// Verify final package type persisted
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
await expect(page.locator("select.package-type-select")).toHaveValue("bottle");
|
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||||
|
|||||||
@@ -87,25 +87,17 @@ test.describe("Medications Page", () => {
|
|||||||
expect(hasPacks || hasTotal).toBeTruthy();
|
expect(hasPacks || hasTotal).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
test("should expose all supported package type options", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
const form = visibleMedForm(page);
|
const form = visibleMedForm(page);
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
const packageSelect = form.locator("select.package-type-select");
|
||||||
|
await expect(packageSelect).toBeVisible();
|
||||||
|
|
||||||
// Find the package type radio buttons or selector
|
const optionValues = await packageSelect
|
||||||
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
.locator("option")
|
||||||
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
|
|
||||||
if (await blisterOption.isVisible().catch(() => false)) {
|
expect(optionValues).toEqual(expect.arrayContaining(["blister", "bottle", "tube", "liquid_container"]));
|
||||||
// Switch to bottle
|
|
||||||
await bottleOption.click();
|
|
||||||
// Bottle-specific fields should appear
|
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Switch back to blister
|
|
||||||
await blisterOption.click();
|
|
||||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have intake schedule with add button", async ({ page }) => {
|
test("should have intake schedule with add button", async ({ page }) => {
|
||||||
|
|||||||
@@ -224,15 +224,4 @@ test.describe("Schedule with medications", () => {
|
|||||||
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show medication names in timeline rows", async ({ page }) => {
|
|
||||||
await navigateTo(page, "/dashboard");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const medNames = todayBlock.locator(".med-name");
|
|
||||||
expect(await medNames.count()).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's medication should show "Alice" badge
|
// Alice's medication should show "Alice" badge
|
||||||
@@ -253,7 +253,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's med has notes — should show the 📝 icon
|
// Alice's med has notes — should show the 📝 icon
|
||||||
@@ -265,7 +265,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes in medication detail modal", async ({ page }) => {
|
test("should show notes in medication detail modal", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on Alice's med to open detail modal
|
// Click on Alice's med to open detail modal
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show all medications in overview table", async ({ page }) => {
|
test("should show all medications in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// All 5 medications should appear
|
// All 5 medications should appear
|
||||||
@@ -139,7 +139,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock med row should have a .status-chip.high
|
// High stock med row should have a .status-chip.high
|
||||||
@@ -151,7 +151,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
||||||
@@ -162,7 +162,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
||||||
@@ -173,7 +173,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
||||||
@@ -184,7 +184,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
||||||
@@ -195,7 +195,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock should show many days (around 299)
|
// High stock should show many days (around 299)
|
||||||
@@ -227,7 +227,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should color-code stock values depending on status", async ({ page }) => {
|
test("should color-code stock values depending on status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock row should have success-text class on stock cells
|
// High stock row should have success-text class on stock cells
|
||||||
@@ -255,7 +255,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should open medication detail modal showing stock info", async ({ page }) => {
|
test("should open medication detail modal showing stock info", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the critical stock medication row
|
// Click on the critical stock medication row
|
||||||
@@ -278,7 +278,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test.describe("MedDetail footer tooltip visibility", () => {
|
|||||||
*/
|
*/
|
||||||
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.17.0",
|
"version": "1.18.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.17.0",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.13",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
@@ -1779,9 +1779,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.2",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
: {};
|
: {};
|
||||||
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
||||||
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
|
// Default to single-worker execution to keep API-seeded E2E suites deterministic.
|
||||||
|
// Still allow explicit local overrides via PLAYWRIGHT_WORKERS.
|
||||||
|
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 1;
|
||||||
|
|
||||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} else {
|
} else {
|
||||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||||
@@ -181,7 +181,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
const res = await fetch("/api/auth/refresh", init);
|
const res = await fetch("/api/auth/refresh", init);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
if (res.status === 401) {
|
||||||
|
log.debug("[Auth] Token refresh rejected (unauthenticated)", { status: res.status, correlationId });
|
||||||
|
} else {
|
||||||
|
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res.ok;
|
return res.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Lightbox, MedicationAvatar } from "../components";
|
import { Lightbox, MedicationAvatar } from "../components";
|
||||||
import { useEscapeKey } from "../hooks";
|
import { useEscapeKey } from "../hooks";
|
||||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
import {
|
||||||
|
getMedDisplayName,
|
||||||
|
getMedTotal,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
@@ -170,7 +177,7 @@ export function MedDetailModal({
|
|||||||
}, [showEditStockModal]);
|
}, [showEditStockModal]);
|
||||||
|
|
||||||
const remainingPrescriptionRefills = Math.max(0, Number(selectedMed?.prescriptionRemainingRefills) || 0);
|
const remainingPrescriptionRefills = Math.max(0, Number(selectedMed?.prescriptionRemainingRefills) || 0);
|
||||||
const prescriptionPackCapEnabled = selectedMed?.packageType === "blister" && usePrescriptionRefill;
|
const prescriptionPackCapEnabled = !isAmountBasedPackageType(selectedMed?.packageType) && usePrescriptionRefill;
|
||||||
const cappedRefillPacks = prescriptionPackCapEnabled
|
const cappedRefillPacks = prescriptionPackCapEnabled
|
||||||
? Math.min(refillPacks, remainingPrescriptionRefills)
|
? Math.min(refillPacks, remainingPrescriptionRefills)
|
||||||
: refillPacks;
|
: refillPacks;
|
||||||
@@ -179,7 +186,7 @@ export function MedDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
if (!showRefillModal) return;
|
if (!showRefillModal) return;
|
||||||
if (selectedMed.packageType !== "blister" || !usePrescriptionRefill) return;
|
if (isAmountBasedPackageType(selectedMed.packageType) || !usePrescriptionRefill) return;
|
||||||
if (refillPacks <= remainingPrescriptionRefills) return;
|
if (refillPacks <= remainingPrescriptionRefills) return;
|
||||||
onRefillPacksChange(remainingPrescriptionRefills);
|
onRefillPacksChange(remainingPrescriptionRefills);
|
||||||
}, [
|
}, [
|
||||||
@@ -192,9 +199,10 @@ export function MedDetailModal({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!selectedMed) return null;
|
if (!selectedMed) return null;
|
||||||
const isAmountPackage = selectedMed.packageType === "tube" || selectedMed.packageType === "liquid_container";
|
const isAmountPackage =
|
||||||
|
isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType);
|
||||||
const amountUnitLabel =
|
const amountUnitLabel =
|
||||||
selectedMed.packageType === "liquid_container" || selectedMed.medicationForm === "liquid"
|
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"
|
||||||
? t("form.packageAmountUnitMl")
|
? t("form.packageAmountUnitMl")
|
||||||
: t("form.packageAmountUnitG");
|
: t("form.packageAmountUnitG");
|
||||||
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
|
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
|
||||||
@@ -202,12 +210,9 @@ export function MedDetailModal({
|
|||||||
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);
|
||||||
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
||||||
const structuralMax =
|
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "bottle" ||
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
selectedMed.packageType === "tube" ||
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
selectedMed.packageType === "liquid_container"
|
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
|
||||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||||
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
||||||
@@ -216,12 +221,9 @@ export function MedDetailModal({
|
|||||||
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
||||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||||
const stockDisplayTotal =
|
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "bottle" ||
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
selectedMed.packageType === "tube" ||
|
: Math.max(0, structuralMax);
|
||||||
selectedMed.packageType === "liquid_container"
|
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
|
||||||
: Math.max(0, structuralMax);
|
|
||||||
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
||||||
const amountPerPackage = (() => {
|
const amountPerPackage = (() => {
|
||||||
const configured = Number(selectedMed.packageAmountValue ?? 0);
|
const configured = Number(selectedMed.packageAmountValue ?? 0);
|
||||||
@@ -244,7 +246,7 @@ export function MedDetailModal({
|
|||||||
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) => {
|
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||||
if (selectedMed.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||||
if (intakeUnit === "tsp") {
|
if (intakeUnit === "tsp") {
|
||||||
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
||||||
}
|
}
|
||||||
@@ -253,7 +255,7 @@ export function MedDetailModal({
|
|||||||
}
|
}
|
||||||
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
||||||
}
|
}
|
||||||
if (selectedMed.packageType === "tube") {
|
if (isTubePackageType(selectedMed.packageType)) {
|
||||||
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||||
}
|
}
|
||||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
@@ -266,12 +268,10 @@ export function MedDetailModal({
|
|||||||
every: blister.every,
|
every: blister.every,
|
||||||
start: blister.start,
|
start: blister.start,
|
||||||
takenBy: null,
|
takenBy: null,
|
||||||
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: false,
|
||||||
intakeUnit: null,
|
intakeUnit: null,
|
||||||
}));
|
}));
|
||||||
const hasAnyIntakeReminder = scheduleIntakes.some(
|
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
|
||||||
(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);
|
||||||
@@ -400,7 +400,7 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
const renderEditStockModal = () => {
|
const renderEditStockModal = () => {
|
||||||
if (!showEditStockModal) return null;
|
if (!showEditStockModal) return null;
|
||||||
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
|
||||||
const liquidBottleCount = Math.max(1, editStockFullBlisters);
|
const liquidBottleCount = Math.max(1, editStockFullBlisters);
|
||||||
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
|
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
|
||||||
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
|
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
|
||||||
@@ -439,7 +439,7 @@ export function MedDetailModal({
|
|||||||
<h2>{t("editStock.title")}</h2>
|
<h2>{t("editStock.title")}</h2>
|
||||||
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
|
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
|
||||||
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
||||||
{selectedMed.packageType === "blister" && (
|
{!isAmountBasedPackageType(selectedMed.packageType) && (
|
||||||
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
||||||
{t("editStock.currentComposition", {
|
{t("editStock.currentComposition", {
|
||||||
fullBlisters: currentFullBlisters,
|
fullBlisters: currentFullBlisters,
|
||||||
@@ -449,10 +449,10 @@ export function MedDetailModal({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selectedMed.packageType === "bottle" && (
|
{isAmountBasedPackageType(selectedMed.packageType) && !isTubePackageType(selectedMed.packageType) && (
|
||||||
<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") && (
|
{(isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType)) && (
|
||||||
<p className="edit-stock-cap-info">
|
<p className="edit-stock-cap-info">
|
||||||
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
|
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
|
||||||
{amountUnitLabel}
|
{amountUnitLabel}
|
||||||
@@ -465,10 +465,7 @@ export function MedDetailModal({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||||
const isBottle =
|
const isBottle = isAmountBasedPackageType(selectedMed.packageType);
|
||||||
selectedMed.packageType === "bottle" ||
|
|
||||||
selectedMed.packageType === "tube" ||
|
|
||||||
selectedMed.packageType === "liquid_container";
|
|
||||||
const enteredTotal = isLiquidPackage
|
const enteredTotal = isLiquidPackage
|
||||||
? Math.min(liquidCapacity, editStockPartialBlisterPills)
|
? Math.min(liquidCapacity, editStockPartialBlisterPills)
|
||||||
: isBottle
|
: isBottle
|
||||||
@@ -813,7 +810,7 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>{t("modal.stockInfo")}</h3>
|
<h3>{t("modal.stockInfo")}</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" && (
|
{!isAmountBasedPackageType(selectedMed.packageType) && (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||||
@@ -832,7 +829,7 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
<div className="med-detail-item full-width">
|
||||||
<span className="med-detail-label">
|
<span className="med-detail-label">
|
||||||
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
|
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
|
||||||
</span>
|
</span>
|
||||||
@@ -858,27 +855,27 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.packageDetails")} (
|
{t("modal.packageDetails")} (
|
||||||
{selectedMed.packageType === "bottle"
|
{isTubePackageType(selectedMed.packageType)
|
||||||
? t("form.packageTypeBottle")
|
? t("form.packageTypeTube")
|
||||||
: selectedMed.packageType === "tube"
|
: isLiquidContainerPackageType(selectedMed.packageType)
|
||||||
? t("form.packageTypeTube")
|
? t("form.packageTypeLiquidContainer")
|
||||||
: selectedMed.packageType === "liquid_container"
|
: isAmountBasedPackageType(selectedMed.packageType)
|
||||||
? t("form.packageTypeLiquidContainer")
|
? t("form.packageTypeBottle")
|
||||||
: t("form.packageTypeBlister")}
|
: t("form.packageTypeBlister")}
|
||||||
)
|
)
|
||||||
{selectedMed.packageType === "tube" && (
|
{isTubePackageType(selectedMed.packageType) && (
|
||||||
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
|
||||||
ℹ️
|
ℹ️
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedMed.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(selectedMed.packageType) && (
|
||||||
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
|
||||||
ℹ️
|
ℹ️
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{!isAmountBasedPackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||||
@@ -893,7 +890,7 @@ 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" ? (
|
) : isLiquidContainerPackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("form.bottles")}</span>
|
<span className="med-detail-label">{t("form.bottles")}</span>
|
||||||
@@ -912,7 +909,7 @@ export function MedDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : selectedMed.packageType === "tube" ? (
|
) : isTubePackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("form.tubes")}</span>
|
<span className="med-detail-label">{t("form.tubes")}</span>
|
||||||
@@ -967,7 +964,7 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.intakeSchedule")}{" "}
|
{t("modal.intakeSchedule")}{" "}
|
||||||
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && (
|
{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>
|
||||||
@@ -978,7 +975,7 @@ export function MedDetailModal({
|
|||||||
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 === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1115,13 +1112,10 @@ export function MedDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
<span className="refill-amount">
|
<span className="refill-amount">
|
||||||
{(() => {
|
{(() => {
|
||||||
const total =
|
const total = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "bottle" ||
|
? entry.loosePillsAdded
|
||||||
selectedMed.packageType === "tube" ||
|
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||||
selectedMed.packageType === "liquid_container"
|
entry.loosePillsAdded;
|
||||||
? entry.loosePillsAdded
|
|
||||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
|
||||||
entry.loosePillsAdded;
|
|
||||||
return `+${total}${isAmountPackage ? ` ${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 && (
|
||||||
@@ -1221,7 +1215,7 @@ export function MedDetailModal({
|
|||||||
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
|
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
|
||||||
|
|
||||||
<div className="refill-form">
|
<div className="refill-form">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{!isAmountBasedPackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("refill.packs")}
|
{t("refill.packs")}
|
||||||
@@ -1265,7 +1259,7 @@ export function MedDetailModal({
|
|||||||
onUsePrescriptionRefillChange(checked);
|
onUsePrescriptionRefillChange(checked);
|
||||||
if (
|
if (
|
||||||
checked &&
|
checked &&
|
||||||
selectedMed.packageType === "blister" &&
|
!isAmountBasedPackageType(selectedMed.packageType) &&
|
||||||
refillPacks > remainingPrescriptionRefills
|
refillPacks > remainingPrescriptionRefills
|
||||||
) {
|
) {
|
||||||
onRefillPacksChange(remainingPrescriptionRefills);
|
onRefillPacksChange(remainingPrescriptionRefills);
|
||||||
@@ -1291,9 +1285,7 @@ export function MedDetailModal({
|
|||||||
className="success"
|
className="success"
|
||||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedMed.packageType === "bottle" ||
|
(isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "tube" ||
|
|
||||||
selectedMed.packageType === "liquid_container"
|
|
||||||
? refillLoose < 1
|
? refillLoose < 1
|
||||||
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
||||||
exceedsPrescriptionPackLimit ||
|
exceedsPrescriptionPackLimit ||
|
||||||
@@ -1303,10 +1295,9 @@ export function MedDetailModal({
|
|||||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const totalRefill =
|
const totalRefill = !isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "blister"
|
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
||||||
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
: refillLoose;
|
||||||
: refillLoose;
|
|
||||||
return totalRefill > 0 ? (
|
return totalRefill > 0 ? (
|
||||||
<span className="refill-preview">
|
<span className="refill-preview">
|
||||||
+{totalRefill}
|
+{totalRefill}
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ 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";
|
||||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||||
import { DOSE_UNITS } from "../types";
|
import {
|
||||||
|
allowsPillFormSelection,
|
||||||
|
DOSE_UNITS,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
PACKAGE_PROFILES,
|
||||||
|
} from "../types";
|
||||||
import { deriveTotal } from "../utils";
|
import { deriveTotal } from "../utils";
|
||||||
import { DateInput } from "./DateInput";
|
import { DateInput } from "./DateInput";
|
||||||
import { FormNumberStepper } from "./FormNumberStepper";
|
import { FormNumberStepper } from "./FormNumberStepper";
|
||||||
@@ -68,7 +75,7 @@ export interface MobileEditModalProps {
|
|||||||
|
|
||||||
/** Calculate total pills from form state */
|
/** Calculate total pills from form state */
|
||||||
function deriveTotalFromForm(form: FormState) {
|
function deriveTotalFromForm(form: FormState) {
|
||||||
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
if (isAmountBasedPackageType(form.packageType)) {
|
||||||
// For bottle type, looseTablets is the current stock
|
// For bottle type, looseTablets is the current stock
|
||||||
return Number(form.looseTablets) || 0;
|
return Number(form.looseTablets) || 0;
|
||||||
}
|
}
|
||||||
@@ -126,19 +133,19 @@ export function MobileEditModal({
|
|||||||
const activeTabIndexRef = useRef(0);
|
const activeTabIndexRef = useRef(0);
|
||||||
|
|
||||||
const allowFractionalIntake = useMemo(() => {
|
const allowFractionalIntake = useMemo(() => {
|
||||||
if (form.packageType === "liquid_container") return true;
|
if (isLiquidContainerPackageType(form.packageType)) return true;
|
||||||
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
if (isTubePackageType(form.packageType)) return form.medicationForm === "liquid";
|
||||||
return form.pillForm === "tablet";
|
return form.pillForm === "tablet";
|
||||||
}, [form.packageType, form.medicationForm, form.pillForm]);
|
}, [form.packageType, form.medicationForm, form.pillForm]);
|
||||||
|
|
||||||
const getUsageLabel = useCallback(
|
const getUsageLabel = useCallback(
|
||||||
(intake: (typeof form.intakes)[number]) => {
|
(intake: (typeof form.intakes)[number]) => {
|
||||||
if (form.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(form.packageType)) {
|
||||||
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
if (intake.intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
||||||
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
if (intake.intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
||||||
return t("form.blisters.usageMl");
|
return t("form.blisters.usageMl");
|
||||||
}
|
}
|
||||||
if (form.packageType === "tube") {
|
if (isTubePackageType(form.packageType)) {
|
||||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||||
}
|
}
|
||||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||||
@@ -147,7 +154,7 @@ export function MobileEditModal({
|
|||||||
[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 = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
|
||||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||||
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");
|
||||||
@@ -432,10 +439,11 @@ export function MobileEditModal({
|
|||||||
value={form.packageType}
|
value={form.packageType}
|
||||||
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
|
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
|
||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
{PACKAGE_PROFILES.map((profile) => (
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option key={profile.value} value={profile.value}>
|
||||||
<option value="tube">{t("form.packageTypeTube")}</option>
|
{t(profile.labelKey)}
|
||||||
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
@@ -446,7 +454,7 @@ export function MobileEditModal({
|
|||||||
placeholder={t("common.optional")}
|
placeholder={t("common.optional")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{allowsPillFormSelection(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillForm")}
|
{t("form.pillForm")}
|
||||||
<select
|
<select
|
||||||
@@ -458,7 +466,7 @@ export function MobileEditModal({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "tube" && (
|
{isTubePackageType(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.medicationForm")}
|
{t("form.medicationForm")}
|
||||||
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
||||||
@@ -466,7 +474,7 @@ export function MobileEditModal({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.medicationForm")}
|
{t("form.medicationForm")}
|
||||||
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
||||||
@@ -560,7 +568,7 @@ export function MobileEditModal({
|
|||||||
<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>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (form.packageType === "blister") {
|
if (!isAmountBasedPackageType(form.packageType)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -601,7 +609,7 @@ export function MobileEditModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.packageType === "tube") {
|
if (isTubePackageType(form.packageType)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -640,7 +648,7 @@ export function MobileEditModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(form.packageType)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -710,19 +718,17 @@ export function MobileEditModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{form.packageType === "bottle" && (
|
{isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
|
||||||
<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">
|
||||||
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
|
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container"
|
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
? ` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{allowsPillFormSelection(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<div className="dose-input-group">
|
<div className="dose-input-group">
|
||||||
@@ -839,7 +845,7 @@ export function MobileEditModal({
|
|||||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(form.packageType) && (
|
||||||
<label className="compact full-row">
|
<label className="compact full-row">
|
||||||
<span>{t("form.blisters.intakeUnit")}</span>
|
<span>{t("form.blisters.intakeUnit")}</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ 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";
|
||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
import { getMedDisplayName, getPackageSize } from "../types";
|
import {
|
||||||
|
getMedDisplayName,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
import { MedicationAvatar } from "./MedicationAvatar";
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
|
||||||
type ReportFormat = "txt" | "md" | "pdf";
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
@@ -299,35 +305,35 @@ function fmtDateTime(iso: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
||||||
if (med.packageType === "liquid_container") return "form.ml";
|
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
|
||||||
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUsageText(med: Medication, usage: number, t: TFn): string {
|
function getUsageText(med: Medication, usage: number, t: TFn): string {
|
||||||
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
return `${usage} ${t(getTubeUnitKey(med))}`;
|
return `${usage} ${t(getTubeUnitKey(med))}`;
|
||||||
}
|
}
|
||||||
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
|
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
||||||
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) });
|
return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) });
|
||||||
}
|
}
|
||||||
return t("report.docTotalCapacity");
|
return t("report.docTotalCapacity");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentStockText(med: Medication, t: TFn): string {
|
function getCurrentStockText(med: Medication, t: TFn): string {
|
||||||
if (med.packageType === "tube" || med.packageType === "liquid_container") {
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
||||||
}
|
}
|
||||||
return `${getPackageSize(med)} ${t("common.pills")}`;
|
return `${getPackageSize(med)} ${t("common.pills")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||||
if (med.packageType === "bottle") return t("report.docBottle");
|
if (isTubePackageType(med.packageType)) return t("report.docTube");
|
||||||
if (med.packageType === "tube") return t("report.docTube");
|
if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer");
|
||||||
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle");
|
||||||
return t("report.docBlister");
|
return t("report.docBlister");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +380,7 @@ function generateTextReport(
|
|||||||
// Package / Stock
|
// Package / Stock
|
||||||
lines.push(h3(t("report.docPackage")));
|
lines.push(h3(t("report.docPackage")));
|
||||||
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
|
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
|
||||||
if (med.packageType === "blister") {
|
if (!isAmountBasedPackageType(med.packageType)) {
|
||||||
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)));
|
||||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
@@ -383,7 +389,7 @@ function generateTextReport(
|
|||||||
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||||
}
|
}
|
||||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||||
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||||
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||||
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||||
@@ -439,7 +445,7 @@ function generateTextReport(
|
|||||||
if (data.refills.length > 0) {
|
if (data.refills.length > 0) {
|
||||||
lines.push(h3(t("report.docRefillHistory")));
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
}
|
}
|
||||||
@@ -572,7 +578,7 @@ function buildPrintHtml(
|
|||||||
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(getReportPackageTypeLabel(med, t))}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
|
||||||
if (med.packageType === "blister") {
|
if (!isAmountBasedPackageType(med.packageType)) {
|
||||||
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>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
||||||
@@ -582,7 +588,7 @@ function buildPrintHtml(
|
|||||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||||
}
|
}
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||||
if (med.packageType !== "tube" && med.packageType !== "liquid_container" && med.pillWeightMg)
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||||
if (med.expiryDate)
|
if (med.expiryDate)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||||
@@ -646,7 +652,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(med.packageType === "tube" || med.packageType === "liquid_container" ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
s += `<li>${entry}</li>`;
|
s += `<li>${entry}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useEscapeKey } from "../hooks";
|
import { useEscapeKey } from "../hooks";
|
||||||
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
||||||
import { getMedDisplayName, getMedTotal } from "../types";
|
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { isDoseDismissed } from "../utils/schedule";
|
import { isDoseDismissed } from "../utils/schedule";
|
||||||
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
||||||
@@ -24,10 +24,10 @@ function getStockStatus(
|
|||||||
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
|
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
|
||||||
packageType?: string
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
if (isTubePackageType(packageType)) 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") {
|
if (isLiquidContainerPackageType(packageType)) {
|
||||||
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
|
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
|
||||||
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
@@ -54,7 +54,7 @@ export function SharedSchedule() {
|
|||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
|
|
||||||
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
||||||
med?.packageType === "liquid_container";
|
isLiquidContainerPackageType(med?.packageType);
|
||||||
|
|
||||||
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
if (unit === "tsp") return usage * 5;
|
if (unit === "tsp") return usage * 5;
|
||||||
@@ -67,7 +67,7 @@ export function SharedSchedule() {
|
|||||||
med: SharedScheduleData["medications"][number] | undefined,
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
||||||
): number => {
|
): number => {
|
||||||
if (med?.packageType === "tube") return 0;
|
if (isTubePackageType(med?.packageType)) return 0;
|
||||||
if (!isLiquidContainerMed(med)) return usage;
|
if (!isLiquidContainerMed(med)) return usage;
|
||||||
return convertLiquidUsageToMl(usage, unit);
|
return convertLiquidUsageToMl(usage, unit);
|
||||||
};
|
};
|
||||||
@@ -140,7 +140,7 @@ export function SharedSchedule() {
|
|||||||
const shouldHideNoScheduleStatusForTube = (
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
med: SharedScheduleData["medications"][number] | undefined,
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
status: { className: string; label: string } | null
|
status: { className: string; label: string } | null
|
||||||
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
const getVisibleStockStatus = (
|
const getVisibleStockStatus = (
|
||||||
med: SharedScheduleData["medications"][number] | undefined,
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
@@ -423,7 +423,12 @@ export function SharedSchedule() {
|
|||||||
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
||||||
const intakes =
|
const intakes =
|
||||||
med.intakes ||
|
med.intakes ||
|
||||||
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
|
med.blisters.map((b) => ({
|
||||||
|
...b,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
intakes.forEach((intake, intakeIdx) => {
|
intakes.forEach((intake, intakeIdx) => {
|
||||||
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
||||||
@@ -535,7 +540,14 @@ export function SharedSchedule() {
|
|||||||
const depletion: Record<string, number | null> = {};
|
const depletion: Record<string, number | null> = {};
|
||||||
|
|
||||||
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,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// 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>();
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||||
import { FIELD_LIMITS } from "../types";
|
import {
|
||||||
|
FIELD_LIMITS,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../types";
|
||||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||||
|
|
||||||
export const defaultBlister = (): FormBlister => {
|
export const defaultBlister = (): FormBlister => {
|
||||||
@@ -213,7 +219,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
every: String(i.every),
|
every: String(i.every),
|
||||||
startDate: toDateValue(i.start),
|
startDate: toDateValue(i.start),
|
||||||
startTime: toTimeValue(i.start),
|
startTime: toTimeValue(i.start),
|
||||||
intakeUnit: i.intakeUnit ?? "ml",
|
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
|
||||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||||
}))
|
}))
|
||||||
@@ -222,7 +228,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
every: String(s.every),
|
every: String(s.every),
|
||||||
startDate: toDateValue(s.start),
|
startDate: toDateValue(s.start),
|
||||||
startTime: toTimeValue(s.start),
|
startTime: toTimeValue(s.start),
|
||||||
intakeUnit: "ml",
|
intakeUnit: "ml" as const,
|
||||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -230,18 +236,19 @@ 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";
|
const packageType = normalizePackageType(med.packageType);
|
||||||
|
const isTubeOrLiquidPackage = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
let normalizedPackCount = String(med.packCount);
|
let normalizedPackCount = String(med.packCount);
|
||||||
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
|
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
|
||||||
|
|
||||||
if (isTubeOrLiquidPackage) {
|
if (isTubeOrLiquidPackage) {
|
||||||
const safePackCount = med.packageType === "tube" ? 1 : Math.max(1, med.packCount || 1);
|
const safePackCount = isTubePackageType(packageType) ? 1 : Math.max(1, med.packCount || 1);
|
||||||
normalizedPackCount = String(safePackCount);
|
normalizedPackCount = String(safePackCount);
|
||||||
|
|
||||||
const rawPackageAmount = Number(med.packageAmountValue ?? 0);
|
const rawPackageAmount = Number(med.packageAmountValue ?? 0);
|
||||||
const legacyKnownAmount = Math.max(0, Number(med.totalPills ?? 0), Number(med.looseTablets ?? 0));
|
const legacyKnownAmount = Math.max(0, Number(med.totalPills ?? 0), Number(med.looseTablets ?? 0));
|
||||||
|
|
||||||
if (med.packageType === "tube") {
|
if (isTubePackageType(packageType)) {
|
||||||
normalizedPackageAmountValue = String(
|
normalizedPackageAmountValue = String(
|
||||||
legacyKnownAmount > 0 ? legacyKnownAmount : Math.max(1, rawPackageAmount)
|
legacyKnownAmount > 0 ? legacyKnownAmount : Math.max(1, rawPackageAmount)
|
||||||
);
|
);
|
||||||
@@ -256,16 +263,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
? Math.max(0, (Number(normalizedPackCount) || 0) * (Number(normalizedPackageAmountValue) || 0))
|
? Math.max(0, (Number(normalizedPackCount) || 0) * (Number(normalizedPackageAmountValue) || 0))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const bottleTotalPills =
|
const bottleTotalPills = isAmountBasedPackageType(packageType) && med.looseTablets ? String(med.looseTablets) : "";
|
||||||
(med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") &&
|
|
||||||
med.looseTablets
|
|
||||||
? String(med.looseTablets)
|
|
||||||
: "";
|
|
||||||
let resolvedForm = med.medicationForm;
|
let resolvedForm = med.medicationForm;
|
||||||
if (!resolvedForm) {
|
if (!resolvedForm) {
|
||||||
if (med.packageType === "tube") {
|
if (isTubePackageType(packageType)) {
|
||||||
resolvedForm = "topical";
|
resolvedForm = "topical";
|
||||||
} else if (med.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(packageType)) {
|
||||||
resolvedForm = "liquid";
|
resolvedForm = "liquid";
|
||||||
} else {
|
} else {
|
||||||
resolvedForm = med.pillForm ?? "tablet";
|
resolvedForm = med.pillForm ?? "tablet";
|
||||||
@@ -273,9 +276,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
}
|
}
|
||||||
const resolvedPillForm = med.pillForm ?? (resolvedForm === "capsule" ? "capsule" : "tablet");
|
const resolvedPillForm = med.pillForm ?? (resolvedForm === "capsule" ? "capsule" : "tablet");
|
||||||
let normalizedPackageAmountUnit = med.packageAmountUnit ?? "ml";
|
let normalizedPackageAmountUnit = med.packageAmountUnit ?? "ml";
|
||||||
if (med.packageType === "tube") {
|
if (isTubePackageType(packageType)) {
|
||||||
normalizedPackageAmountUnit = "g";
|
normalizedPackageAmountUnit = "g";
|
||||||
} else if (med.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(packageType)) {
|
||||||
normalizedPackageAmountUnit = "ml";
|
normalizedPackageAmountUnit = "ml";
|
||||||
}
|
}
|
||||||
let resolvedTotalPills = bottleTotalPills;
|
let resolvedTotalPills = bottleTotalPills;
|
||||||
@@ -291,7 +294,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
medicationForm: resolvedForm,
|
medicationForm: resolvedForm,
|
||||||
pillForm: resolvedPillForm,
|
pillForm: resolvedPillForm,
|
||||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
packageType: med.packageType ?? "blister",
|
packageType,
|
||||||
packCount: normalizedPackCount,
|
packCount: normalizedPackCount,
|
||||||
blistersPerPack: String(med.blistersPerPack),
|
blistersPerPack: String(med.blistersPerPack),
|
||||||
pillsPerBlister: String(med.pillsPerBlister),
|
pillsPerBlister: String(med.pillsPerBlister),
|
||||||
@@ -347,14 +350,14 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const next = { ...prev, [key]: value } as FormState;
|
const next = { ...prev, [key]: value } as FormState;
|
||||||
|
|
||||||
if (key === "packageType") {
|
if (key === "packageType") {
|
||||||
if (value === "tube") {
|
if (isTubePackageType(value)) {
|
||||||
next.packCount = "1";
|
next.packCount = "1";
|
||||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
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 (isLiquidContainerPackageType(value)) {
|
||||||
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
|
next.packCount = String(Math.max(1, Number(next.packCount) || 1));
|
||||||
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0));
|
||||||
next.medicationForm = "liquid";
|
next.medicationForm = "liquid";
|
||||||
@@ -369,12 +372,12 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === "medicationForm") {
|
if (key === "medicationForm") {
|
||||||
if (next.packageType === "tube") {
|
if (isTubePackageType(next.packageType)) {
|
||||||
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 (next.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(next.packageType)) {
|
||||||
next.medicationForm = "liquid";
|
next.medicationForm = "liquid";
|
||||||
next.lifecycleCategory = "refill_when_empty";
|
next.lifecycleCategory = "refill_when_empty";
|
||||||
next.doseUnit = "ml";
|
next.doseUnit = "ml";
|
||||||
@@ -383,10 +386,10 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.packageType === "tube") {
|
if (isTubePackageType(next.packageType)) {
|
||||||
next.packCount = "1";
|
next.packCount = "1";
|
||||||
next.packageAmountUnit = "g";
|
next.packageAmountUnit = "g";
|
||||||
} else if (next.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(next.packageType)) {
|
||||||
next.packageAmountUnit = "ml";
|
next.packageAmountUnit = "ml";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||||
import { getMedTotal, getPackageSize } from "../types";
|
import {
|
||||||
|
getMedTotal,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
export interface UseRefillReturn {
|
export interface UseRefillReturn {
|
||||||
// Refill state
|
// Refill state
|
||||||
@@ -137,10 +143,9 @@ export function useRefill(): UseRefillReturn {
|
|||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockSaving(true);
|
setEditStockSaving(true);
|
||||||
try {
|
try {
|
||||||
const isTubePackage = selectedMed.packageType === "tube";
|
const isTubePackage = isTubePackageType(selectedMed.packageType);
|
||||||
const isBottlePackage = selectedMed.packageType === "bottle";
|
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
|
||||||
const isLiquidPackage = selectedMed.packageType === "liquid_container";
|
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
|
||||||
const isAmountPackage = isBottlePackage || isTubePackage || isLiquidPackage;
|
|
||||||
const liquidAmountPerBottle = Math.max(
|
const liquidAmountPerBottle = Math.max(
|
||||||
1,
|
1,
|
||||||
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
|
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
|
||||||
@@ -268,10 +273,7 @@ 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 =
|
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
|
||||||
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);
|
||||||
@@ -282,7 +284,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
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);
|
||||||
let fullBlisters: number;
|
let fullBlisters: number;
|
||||||
if (selectedMed.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||||
fullBlisters = Math.max(1, selectedMed.packCount);
|
fullBlisters = Math.max(1, selectedMed.packCount);
|
||||||
} else if (isAmountPackage) {
|
} else if (isAmountPackage) {
|
||||||
fullBlisters = 0;
|
fullBlisters = 0;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface Settings {
|
|||||||
upcomingTodayOnly: boolean;
|
upcomingTodayOnly: boolean;
|
||||||
shareScheduleTodayOnly: boolean;
|
shareScheduleTodayOnly: boolean;
|
||||||
swapDashboardMainSections: boolean;
|
swapDashboardMainSections: boolean;
|
||||||
|
reminderHour: number;
|
||||||
|
reminderMinutesBefore: number;
|
||||||
expiryWarningDays: number;
|
expiryWarningDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +98,8 @@ const defaultSettings: Settings = {
|
|||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
|
reminderHour: 6,
|
||||||
|
reminderMinutesBefore: 15,
|
||||||
expiryWarningDays: 30,
|
expiryWarningDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -323,9 +323,9 @@
|
|||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Erinnerungsplan",
|
"title": "Erinnerungsplan",
|
||||||
"stockCheck": "Bestands- & Rezeptprüfung",
|
"stockCheck": "Bestands- & Rezeptprüfung",
|
||||||
"dailyAt6": "Täglich um 6:00 Uhr",
|
"dailyAtHour": "Täglich um {{hour}}:00 Uhr",
|
||||||
"intakeCheck": "Einnahmeprüfung",
|
"intakeCheck": "Einnahmeprüfung",
|
||||||
"15minBefore": "15 Min. vor geplanter Zeit",
|
"minutesBefore": "{{minutes}} Min. vor geplanter Zeit",
|
||||||
"nextCheck": "Nächste Bestandsprüfung",
|
"nextCheck": "Nächste Bestandsprüfung",
|
||||||
"lastSent": "Letzte Benachrichtigung",
|
"lastSent": "Letzte Benachrichtigung",
|
||||||
"lastStockSent": "Letzte Bestands-Erinnerung",
|
"lastStockSent": "Letzte Bestands-Erinnerung",
|
||||||
|
|||||||
@@ -323,9 +323,9 @@
|
|||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Reminder Schedule",
|
"title": "Reminder Schedule",
|
||||||
"stockCheck": "Stock & prescription check",
|
"stockCheck": "Stock & prescription check",
|
||||||
"dailyAt6": "Daily at 6:00 AM",
|
"dailyAtHour": "Daily at {{hour}}:00",
|
||||||
"intakeCheck": "Intake check",
|
"intakeCheck": "Intake check",
|
||||||
"15minBefore": "15 min before scheduled time",
|
"minutesBefore": "{{minutes}} min before scheduled time",
|
||||||
"nextCheck": "Next stock check",
|
"nextCheck": "Next stock check",
|
||||||
"lastSent": "Last notification sent",
|
"lastSent": "Last notification sent",
|
||||||
"lastStockSent": "Last stock reminder",
|
"lastStockSent": "Last stock reminder",
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { ConfirmModal, MedicationAvatar } from "../components";
|
|||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import { useModalHistory } from "../hooks";
|
import { useModalHistory } from "../hooks";
|
||||||
import { type Coverage, getMedDisplayName } from "../types";
|
import {
|
||||||
|
allowsPillFormSelection,
|
||||||
|
type Coverage,
|
||||||
|
getMedDisplayName,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||||
import {
|
import {
|
||||||
@@ -132,15 +139,15 @@ 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, value: number) =>
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||||
? t("form.packageAmountUnitMl")
|
? t("form.packageAmountUnitMl")
|
||||||
: t("form.blisters.applications", { count: Math.abs(value) });
|
: 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 (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
return `${formatNumber(medsLeft)} ${t("form.packageAmountUnitMl")}`;
|
return `${formatNumber(medsLeft)} ${t("form.packageAmountUnitMl")}`;
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med, medsLeft)}`;
|
return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med, medsLeft)}`;
|
||||||
}
|
}
|
||||||
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||||
@@ -177,10 +184,10 @@ export function DashboardPage() {
|
|||||||
usage: number,
|
usage: number,
|
||||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
) => {
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
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")}`;
|
||||||
@@ -192,7 +199,7 @@ export function DashboardPage() {
|
|||||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
||||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
) => {
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
if (doses && doses.length > 0) {
|
if (doses && doses.length > 0) {
|
||||||
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
if (normalizedDoses.length > 0) {
|
if (normalizedDoses.length > 0) {
|
||||||
@@ -214,7 +221,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return formatLiquidUsageLabel(total, intakeUnit);
|
return formatLiquidUsageLabel(total, intakeUnit);
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return `${total} ${getTubeUnitLabel(med, total)}`;
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
}
|
}
|
||||||
return t("common.pillsTotal", { count: total });
|
return t("common.pillsTotal", { count: total });
|
||||||
@@ -245,7 +252,7 @@ export function DashboardPage() {
|
|||||||
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
||||||
const normalizedUsage = (usage * personMultiplier) / every;
|
const normalizedUsage = (usage * personMultiplier) / every;
|
||||||
|
|
||||||
if (med.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med.packageType)) {
|
||||||
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
||||||
} else {
|
} else {
|
||||||
dailyTotal += normalizedUsage;
|
dailyTotal += normalizedUsage;
|
||||||
@@ -254,11 +261,11 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
if (dailyTotal <= 0) return "-";
|
if (dailyTotal <= 0) return "-";
|
||||||
|
|
||||||
if (med.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med.packageType)) {
|
||||||
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: t("form.packageAmountUnitMl") });
|
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: t("form.packageAmountUnitMl") });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (med.packageType === "tube") {
|
if (isTubePackageType(med.packageType)) {
|
||||||
const tubeUnit =
|
const tubeUnit =
|
||||||
med.medicationForm === "liquid"
|
med.medicationForm === "liquid"
|
||||||
? t("form.packageAmountUnitMl")
|
? t("form.packageAmountUnitMl")
|
||||||
@@ -273,7 +280,7 @@ export function DashboardPage() {
|
|||||||
const shouldHideNoScheduleStatusForTube = (
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
med: (typeof meds)[number] | undefined,
|
med: (typeof meds)[number] | undefined,
|
||||||
status: { className: string; label: string } | null
|
status: { className: string; label: string } | null
|
||||||
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
const getVisibleStockStatus = (
|
const getVisibleStockStatus = (
|
||||||
med: (typeof meds)[number] | undefined,
|
med: (typeof meds)[number] | undefined,
|
||||||
@@ -746,9 +753,7 @@ export function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.stock")} className={textClass}>
|
<span data-label={t("table.stock")} className={textClass}>
|
||||||
{med?.packageType === "bottle" ||
|
{isAmountBasedPackageType(med?.packageType)
|
||||||
med?.packageType === "tube" ||
|
|
||||||
med?.packageType === "liquid_container"
|
|
||||||
? formatStockLabel(med, row.medsLeft)
|
? formatStockLabel(med, row.medsLeft)
|
||||||
: formatFullBlisters(stock.fullBlisters, t)}
|
: formatFullBlisters(stock.fullBlisters, t)}
|
||||||
</span>
|
</span>
|
||||||
@@ -757,11 +762,9 @@ export function DashboardPage() {
|
|||||||
</span>
|
</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}${isAmountBasedPackageType(med?.packageType) ? " hide-on-card" : ""}`}
|
||||||
>
|
>
|
||||||
{med?.packageType === "bottle" ||
|
{isAmountBasedPackageType(med?.packageType)
|
||||||
med?.packageType === "tube" ||
|
|
||||||
med?.packageType === "liquid_container"
|
|
||||||
? "—"
|
? "—"
|
||||||
: formatOpenBlisterAndLoose(
|
: formatOpenBlisterAndLoose(
|
||||||
stock.openBlisterPills,
|
stock.openBlisterPills,
|
||||||
@@ -958,11 +961,9 @@ export function DashboardPage() {
|
|||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||||||
med?.packageType !== "liquid_container" &&
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
med?.pillWeightMg && (
|
)}
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -1005,7 +1006,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -1240,11 +1242,9 @@ export function DashboardPage() {
|
|||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||||||
med?.packageType !== "liquid_container" &&
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
med?.pillWeightMg && (
|
)}
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -1287,7 +1287,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -1485,11 +1486,9 @@ export function DashboardPage() {
|
|||||||
<span className="dose-usage-main">
|
<span className="dose-usage-main">
|
||||||
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
{formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)}
|
||||||
</span>
|
</span>
|
||||||
{med?.packageType !== "tube" &&
|
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||||||
med?.packageType !== "liquid_container" &&
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
med?.pillWeightMg && (
|
)}
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
@@ -1532,7 +1531,8 @@ export function DashboardPage() {
|
|||||||
🤖
|
🤖
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
↩
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,8 +17,20 @@ import {
|
|||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, FormState, Medication } from "../types";
|
import type { DoseUnit, FormState, Medication, PackageType } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
import {
|
||||||
|
allowsPillFormSelection,
|
||||||
|
DOSE_UNITS,
|
||||||
|
FIELD_LIMITS,
|
||||||
|
getMedDisplayName,
|
||||||
|
getPackageProfile,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
PACKAGE_PROFILES,
|
||||||
|
} from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
@@ -239,7 +251,7 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Calculate total tablets
|
// Calculate total tablets
|
||||||
const totalTablets = useMemo(() => {
|
const totalTablets = useMemo(() => {
|
||||||
if (form.packageType === "bottle" || form.packageType === "tube" || form.packageType === "liquid_container") {
|
if (isAmountBasedPackageType(form.packageType)) {
|
||||||
// For bottle type, looseTablets is the current stock
|
// For bottle type, looseTablets is the current stock
|
||||||
return Number(form.looseTablets) || 0;
|
return Number(form.looseTablets) || 0;
|
||||||
}
|
}
|
||||||
@@ -274,41 +286,43 @@ export function MedicationsPage() {
|
|||||||
}, [form.medicationStartDate, form.medicationEndDate, form.intakes, t]);
|
}, [form.medicationStartDate, form.medicationEndDate, form.intakes, t]);
|
||||||
|
|
||||||
const allowFractionalIntake = useMemo(() => {
|
const allowFractionalIntake = useMemo(() => {
|
||||||
if (form.packageType === "liquid_container") return true;
|
if (isLiquidContainerPackageType(form.packageType)) return true;
|
||||||
if (form.packageType === "tube") return form.medicationForm === "liquid";
|
if (isTubePackageType(form.packageType)) return form.medicationForm === "liquid";
|
||||||
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(
|
||||||
if (form.packageType === "liquid_container") {
|
(intakeUnit: "ml" | "tsp" | "tbsp") => {
|
||||||
return t("form.blisters.usageMl");
|
if (isLiquidContainerPackageType(form.packageType)) {
|
||||||
}
|
if (intakeUnit === "tsp") return t("form.blisters.usageTsp");
|
||||||
if (form.packageType === "tube") {
|
if (intakeUnit === "tbsp") return t("form.blisters.usageTbsp");
|
||||||
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
return t("form.blisters.usageMl");
|
||||||
}
|
}
|
||||||
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
if (isTubePackageType(form.packageType)) {
|
||||||
return t("form.blisters.usageTablets");
|
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
|
||||||
}, [form.packageType, form.medicationForm, form.pillForm, t]);
|
}
|
||||||
|
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
|
||||||
|
return t("form.blisters.usageTablets");
|
||||||
|
},
|
||||||
|
[form.packageType, form.medicationForm, form.pillForm, t]
|
||||||
|
);
|
||||||
|
|
||||||
const usesAmountLabels = form.packageType === "tube" || form.packageType === "liquid_container";
|
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
|
||||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||||
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(
|
const getMedicationPackageTypeLabel = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
if (med.packageType === "bottle") return t("form.packageTypeBottle");
|
return t(getPackageProfile(med.packageType).labelKey);
|
||||||
if (med.packageType === "tube") return t("form.packageTypeTube");
|
|
||||||
if (med.packageType === "liquid_container") return t("form.packageTypeLiquidContainer");
|
|
||||||
return t("form.packageTypeBlister");
|
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getMedicationStockSuffix = useCallback(
|
const getMedicationStockSuffix = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
if (med.packageType === "tube") return "";
|
if (isTubePackageType(med.packageType)) return "";
|
||||||
if (med.packageType === "liquid_container") return " ml";
|
if (isLiquidContainerPackageType(med.packageType)) return " ml";
|
||||||
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
|
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
@@ -316,10 +330,10 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const getMedicationUsageUnitLabel = useCallback(
|
const getMedicationUsageUnitLabel = useCallback(
|
||||||
(med: Medication, usage: number) => {
|
(med: Medication, usage: number) => {
|
||||||
if (med.packageType === "tube") {
|
if (isTubePackageType(med.packageType)) {
|
||||||
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
|
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
|
||||||
}
|
}
|
||||||
if (med.packageType === "liquid_container") return "ml";
|
if (isLiquidContainerPackageType(med.packageType)) return "ml";
|
||||||
if (usage === 1) return t("common.pill");
|
if (usage === 1) return t("common.pill");
|
||||||
return t("common.pills");
|
return t("common.pills");
|
||||||
},
|
},
|
||||||
@@ -522,7 +536,7 @@ export function MedicationsPage() {
|
|||||||
usage: Number(intake.usage) || 1,
|
usage: Number(intake.usage) || 1,
|
||||||
every: Number(intake.every) || 1,
|
every: Number(intake.every) || 1,
|
||||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||||
intakeUnit: form.packageType === "liquid_container" ? intake.intakeUnit : null,
|
intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null,
|
||||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||||
}));
|
}));
|
||||||
@@ -539,22 +553,23 @@ export function MedicationsPage() {
|
|||||||
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
const lowRefillThreshold = Math.min(Number(form.prescriptionLowRefillThreshold || 1), authorizedRefills);
|
||||||
|
|
||||||
let derivedMedicationForm: string;
|
let derivedMedicationForm: string;
|
||||||
if (form.packageType === "tube") {
|
if (isTubePackageType(form.packageType)) {
|
||||||
derivedMedicationForm =
|
derivedMedicationForm =
|
||||||
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
|
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
|
||||||
} else if (form.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(form.packageType)) {
|
||||||
derivedMedicationForm = "liquid";
|
derivedMedicationForm = "liquid";
|
||||||
} else {
|
} else {
|
||||||
derivedMedicationForm = form.pillForm;
|
derivedMedicationForm = form.pillForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tubeTotalAmount =
|
const tubeTotalAmount = isTubePackageType(form.packageType)
|
||||||
form.packageType === "tube" ? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0) : null;
|
? (Number(form.packCount) || 0) * (Number(form.packageAmountValue ?? 0) || 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
let packageAmountUnit = form.packageAmountUnit ?? "ml";
|
let packageAmountUnit = form.packageAmountUnit ?? "ml";
|
||||||
if (form.packageType === "tube") {
|
if (isTubePackageType(form.packageType)) {
|
||||||
packageAmountUnit = "g";
|
packageAmountUnit = "g";
|
||||||
} else if (form.packageType === "liquid_container") {
|
} else if (isLiquidContainerPackageType(form.packageType)) {
|
||||||
packageAmountUnit = "ml";
|
packageAmountUnit = "ml";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,16 +578,19 @@ export function MedicationsPage() {
|
|||||||
genericName: form.genericName.trim() || null,
|
genericName: form.genericName.trim() || null,
|
||||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||||
medicationForm: derivedMedicationForm,
|
medicationForm: derivedMedicationForm,
|
||||||
pillForm: form.packageType === "tube" || form.packageType === "liquid_container" ? null : form.pillForm,
|
pillForm:
|
||||||
|
isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType) ? null : form.pillForm,
|
||||||
lifecycleCategory: form.lifecycleCategory,
|
lifecycleCategory: form.lifecycleCategory,
|
||||||
packageType: form.packageType,
|
packageType: normalizePackageType(form.packageType),
|
||||||
packCount: form.packageType === "tube" ? Math.max(1, Number(form.packCount) || 1) : Number(form.packCount) || 0,
|
packCount: isTubePackageType(form.packageType)
|
||||||
blistersPerPack: form.packageType === "tube" ? 1 : Number(form.blistersPerPack) || 1,
|
? Math.max(1, Number(form.packCount) || 1)
|
||||||
pillsPerBlister: form.packageType === "tube" ? 1 : Number(form.pillsPerBlister) || 1,
|
: Number(form.packCount) || 0,
|
||||||
|
blistersPerPack: isTubePackageType(form.packageType) ? 1 : Number(form.blistersPerPack) || 1,
|
||||||
|
pillsPerBlister: isTubePackageType(form.packageType) ? 1 : Number(form.pillsPerBlister) || 1,
|
||||||
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
packageAmountValue: Number(form.packageAmountValue ?? 0) || 0,
|
||||||
packageAmountUnit,
|
packageAmountUnit,
|
||||||
totalPills: form.packageType === "tube" ? tubeTotalAmount : Number(form.totalPills) || null,
|
totalPills: isTubePackageType(form.packageType) ? tubeTotalAmount : Number(form.totalPills) || null,
|
||||||
looseTablets: form.packageType === "tube" ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
looseTablets: isTubePackageType(form.packageType) ? tubeTotalAmount || 0 : Number(form.looseTablets) || 0,
|
||||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||||
doseUnit: form.doseUnit,
|
doseUnit: form.doseUnit,
|
||||||
medicationStartDate: form.medicationStartDate || null,
|
medicationStartDate: form.medicationStartDate || null,
|
||||||
@@ -976,7 +994,7 @@ export function MedicationsPage() {
|
|||||||
<span>
|
<span>
|
||||||
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
|
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
|
||||||
</span>
|
</span>
|
||||||
{med.packageType === "blister" ? (
|
{!isAmountBasedPackageType(med.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<span>
|
<span>
|
||||||
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
|
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
|
||||||
@@ -1009,7 +1027,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" ? "" : getMedicationStockSuffix(med)}
|
{getMedicationStockSuffix(med)}
|
||||||
{(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) && (
|
||||||
@@ -1245,17 +1263,24 @@ export function MedicationsPage() {
|
|||||||
<select
|
<select
|
||||||
className="package-type-select"
|
className="package-type-select"
|
||||||
value={form.packageType}
|
value={form.packageType}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleValueChange("packageType", e.target.value as PackageType)}
|
||||||
handleValueChange("packageType", e.target.value as import("../types").PackageType)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
{PACKAGE_PROFILES.map((profile) => (
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option key={profile.value} value={profile.value}>
|
||||||
<option value="tube">{t("form.packageTypeTube")}</option>
|
{t(profile.labelKey)}
|
||||||
<option value="liquid_container">{t("form.packageTypeLiquidContainer")}</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
<label>
|
||||||
|
{t("form.medicationEndDate")}
|
||||||
|
<DateInput
|
||||||
|
value={form.medicationEndDate}
|
||||||
|
onChange={(e) => handleValueChange("medicationEndDate", e.target.value)}
|
||||||
|
placeholder={t("common.optional")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{allowsPillFormSelection(form.packageType) && (
|
||||||
<label>
|
<label>
|
||||||
{t("form.pillForm")}
|
{t("form.pillForm")}
|
||||||
<select
|
<select
|
||||||
@@ -1267,7 +1292,7 @@ export function MedicationsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "tube" && (
|
{isTubePackageType(form.packageType) && (
|
||||||
<label>
|
<label>
|
||||||
{t("form.medicationForm")}
|
{t("form.medicationForm")}
|
||||||
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
<select value={"topical"} onChange={() => handleValueChange("medicationForm", "topical")}>
|
||||||
@@ -1275,7 +1300,7 @@ export function MedicationsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(form.packageType) && (
|
||||||
<label>
|
<label>
|
||||||
{t("form.medicationForm")}
|
{t("form.medicationForm")}
|
||||||
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
<select value={"liquid"} onChange={() => handleValueChange("medicationForm", "liquid")}>
|
||||||
@@ -1283,14 +1308,6 @@ export function MedicationsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label>
|
|
||||||
{t("form.medicationEndDate")}
|
|
||||||
<DateInput
|
|
||||||
value={form.medicationEndDate}
|
|
||||||
onChange={(e) => handleValueChange("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")}
|
||||||
@@ -1418,7 +1435,7 @@ export function MedicationsPage() {
|
|||||||
<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>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (form.packageType === "blister") {
|
if (!isAmountBasedPackageType(form.packageType)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -1459,7 +1476,7 @@ export function MedicationsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.packageType === "tube") {
|
if (isTubePackageType(form.packageType)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
@@ -1531,7 +1548,7 @@ export function MedicationsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{form.packageType !== "tube" && form.packageType !== "liquid_container" && (
|
{allowsPillFormSelection(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<div className="dose-input-group">
|
<div className="dose-input-group">
|
||||||
@@ -1557,7 +1574,7 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{(form.packageType === "bottle" || form.packageType === "liquid_container") && (
|
{isAmountBasedPackageType(form.packageType) && !isTubePackageType(form.packageType) && (
|
||||||
<div className="full stock-total-row">
|
<div className="full stock-total-row">
|
||||||
<label className="stock-total-field">
|
<label className="stock-total-field">
|
||||||
{totalLabel}
|
{totalLabel}
|
||||||
@@ -1565,7 +1582,7 @@ export function MedicationsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(form.packageType) && (
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.packageAmount")}
|
{t("form.packageAmount")}
|
||||||
<div className="dose-input-group">
|
<div className="dose-input-group">
|
||||||
@@ -1703,7 +1720,7 @@ export function MedicationsPage() {
|
|||||||
<div key={idx} className="blister-row">
|
<div key={idx} className="blister-row">
|
||||||
<div className="blister-inputs">
|
<div className="blister-inputs">
|
||||||
<label>
|
<label>
|
||||||
{usageLabel}
|
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||||
<FormNumberStepper
|
<FormNumberStepper
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||||
@@ -1739,7 +1756,7 @@ export function MedicationsPage() {
|
|||||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.packageType === "liquid_container" && (
|
{isLiquidContainerPackageType(form.packageType) && (
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.intakeUnit")}
|
{t("form.blisters.intakeUnit")}
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DateTimeInput, MedicationAvatar } from "../components";
|
|||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { PlannerRow } from "../types";
|
import type { PlannerRow } from "../types";
|
||||||
import { getMedDisplayName } from "../types";
|
import { getMedDisplayName, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { toInputValue } from "../utils/formatters";
|
import { toInputValue } from "../utils/formatters";
|
||||||
|
|
||||||
// Date helpers
|
// Date helpers
|
||||||
@@ -124,10 +124,10 @@ export function PlannerPage() {
|
|||||||
|
|
||||||
const getUsageUnitLabel = (medicationId: number, count: number): string => {
|
const getUsageUnitLabel = (medicationId: number, count: number): string => {
|
||||||
const med = meds.find((m) => m.id === medicationId);
|
const med = meds.find((m) => m.id === medicationId);
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
return t("form.ml");
|
return t("form.ml");
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
return med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||||
}
|
}
|
||||||
return count === 1 ? t("common.pill") : t("common.pills");
|
return count === 1 ? t("common.pill") : t("common.pills");
|
||||||
@@ -136,10 +136,10 @@ export function PlannerPage() {
|
|||||||
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
|
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
|
||||||
const med = meds.find((m) => m.id === medicationId);
|
const med = meds.find((m) => m.id === medicationId);
|
||||||
const roundedLoose = Math.round(loosePills * 10) / 10;
|
const roundedLoose = Math.round(loosePills * 10) / 10;
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
return `${roundedLoose} ${t("form.ml")}`;
|
return `${roundedLoose} ${t("form.ml")}`;
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
const unit = med.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
|
||||||
return `${roundedLoose} ${unit}`;
|
return `${roundedLoose} ${unit}`;
|
||||||
}
|
}
|
||||||
@@ -254,17 +254,11 @@ export function PlannerPage() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("planner.table.blisters")}>
|
<span data-label={t("planner.table.blisters")}>
|
||||||
{row.packageType === "bottle" ||
|
{isAmountBasedPackageType(row.packageType) ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||||
row.packageType === "tube" ||
|
|
||||||
row.packageType === "liquid_container"
|
|
||||||
? "–"
|
|
||||||
: `${row.blistersNeeded} × ${row.blisterSize}`}
|
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
||||||
<span data-label={t("planner.table.available")}>
|
<span data-label={t("planner.table.available")}>
|
||||||
{row.packageType === "bottle" ||
|
{isAmountBasedPackageType(row.packageType) ? (
|
||||||
row.packageType === "tube" ||
|
|
||||||
row.packageType === "liquid_container" ? (
|
|
||||||
getAvailableLabel(row.medicationId, row.loosePills)
|
getAvailableLabel(row.medicationId, row.loosePills)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MedicationAvatar } from "../components";
|
|||||||
import { useAuth } from "../components/Auth";
|
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, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { formatNumber } from "../utils/formatters";
|
import { formatNumber } from "../utils/formatters";
|
||||||
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ function getStockStatus(
|
|||||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number },
|
||||||
packageType?: string
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
if (packageType === "tube") return { className: "success", label: "status.noSchedule" };
|
if (isTubePackageType(packageType)) 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") {
|
if (isLiquidContainerPackageType(packageType)) {
|
||||||
const lowDays = Math.max(1, Math.floor(settings.reminderDaysBefore));
|
const lowDays = Math.max(1, Math.floor(settings.reminderDaysBefore));
|
||||||
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
@@ -95,10 +95,10 @@ export function SchedulePage() {
|
|||||||
const shouldHideNoScheduleStatusForTube = (
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
med: (typeof meds)[number] | undefined,
|
med: (typeof meds)[number] | undefined,
|
||||||
status: { className: string; label: string } | null
|
status: { className: string; label: string } | null
|
||||||
) => med?.packageType === "tube" && status?.label === "status.noSchedule";
|
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||||
med?.packageType === "liquid_container" || med?.medicationForm === "liquid"
|
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||||
? t("form.packageAmountUnitMl")
|
? t("form.packageAmountUnitMl")
|
||||||
: t("form.blisters.applications", { count: Math.abs(value) });
|
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||||
|
|
||||||
@@ -133,10 +133,10 @@ export function SchedulePage() {
|
|||||||
usage: number,
|
usage: number,
|
||||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
) => {
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return `${usage} ${getTubeUnitLabel(med, usage)}`;
|
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")}`;
|
||||||
@@ -147,7 +147,7 @@ export function SchedulePage() {
|
|||||||
total: number,
|
total: number,
|
||||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
) => {
|
) => {
|
||||||
if (med?.packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||||
if (doses && doses.length > 0) {
|
if (doses && doses.length > 0) {
|
||||||
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
if (normalizedDoses.length > 0) {
|
if (normalizedDoses.length > 0) {
|
||||||
@@ -167,7 +167,7 @@ export function SchedulePage() {
|
|||||||
}
|
}
|
||||||
return `${formatNumber(total)} ${t("form.packageAmountUnitMl")}`;
|
return `${formatNumber(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
}
|
}
|
||||||
if (med?.packageType === "tube") {
|
if (isTubePackageType(med?.packageType)) {
|
||||||
return `${total} ${getTubeUnitLabel(med, total)}`;
|
return `${total} ${getTubeUnitLabel(med, total)}`;
|
||||||
}
|
}
|
||||||
return t("common.pillsTotal", { count: total });
|
return t("common.pillsTotal", { count: total });
|
||||||
|
|||||||
@@ -479,11 +479,15 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="schedule-row">
|
<div className="schedule-row">
|
||||||
<span className="schedule-label">{t("settings.schedule.stockCheck")}</span>
|
<span className="schedule-label">{t("settings.schedule.stockCheck")}</span>
|
||||||
<span className="schedule-value">{t("settings.schedule.dailyAt6")}</span>
|
<span className="schedule-value">
|
||||||
|
{t("settings.schedule.dailyAtHour", { hour: settings.reminderHour })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-row">
|
<div className="schedule-row">
|
||||||
<span className="schedule-label">{t("settings.schedule.intakeCheck")}</span>
|
<span className="schedule-label">{t("settings.schedule.intakeCheck")}</span>
|
||||||
<span className="schedule-value">{t("settings.schedule.15minBefore")}</span>
|
<span className="schedule-value">
|
||||||
|
{t("settings.schedule.minutesBefore", { minutes: settings.reminderMinutesBefore })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{settings.nextScheduledCheck && (
|
{settings.nextScheduledCheck && (
|
||||||
<div className="schedule-row">
|
<div className="schedule-row">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Coverage, Medication, PackageType } from "../types";
|
import type { Coverage, Medication, PackageType } from "../types";
|
||||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
@@ -78,7 +78,7 @@ export function getReminderStatusData(
|
|||||||
|
|
||||||
for (const c of allCoverage) {
|
for (const c of allCoverage) {
|
||||||
const med = medByName.get(c.name);
|
const med = medByName.get(c.name);
|
||||||
if (med?.packageType === "tube") continue;
|
if (isTubePackageType(med?.packageType)) 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 });
|
||||||
@@ -88,7 +88,7 @@ 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 isLiquid = med?.packageType === "liquid_container";
|
const isLiquid = isLiquidContainerPackageType(med?.packageType);
|
||||||
const liquidLowDays = Math.max(1, Math.floor(reminderDaysBefore));
|
const liquidLowDays = Math.max(1, Math.floor(reminderDaysBefore));
|
||||||
const liquidCriticalDays = Math.max(1, Math.ceil(liquidLowDays / 2));
|
const liquidCriticalDays = Math.max(1, Math.ceil(liquidLowDays / 2));
|
||||||
const isCritical = isLiquid ? c.daysLeft <= liquidCriticalDays : c.daysLeft <= reminderDaysBefore;
|
const isCritical = isLiquid ? c.daysLeft <= liquidCriticalDays : c.daysLeft <= reminderDaysBefore;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ body.modal-open {
|
|||||||
.route-transition-mask.active {
|
.route-transition-mask.active {
|
||||||
transition: none;
|
transition: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const defaultForm: FormState = {
|
|||||||
packCount: "1",
|
packCount: "1",
|
||||||
blistersPerPack: "1",
|
blistersPerPack: "1",
|
||||||
pillsPerBlister: "1",
|
pillsPerBlister: "1",
|
||||||
|
packageAmountValue: "0",
|
||||||
|
packageAmountUnit: "ml",
|
||||||
looseTablets: "0",
|
looseTablets: "0",
|
||||||
totalPills: "",
|
totalPills: "",
|
||||||
pillWeightMg: "",
|
pillWeightMg: "",
|
||||||
|
|||||||
@@ -1286,9 +1286,9 @@ describe("getNextReminderForMed", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockT = (key: string, options?: Record<string, number>) => {
|
const mockT = (key: string, options?: Record<string, unknown>) => {
|
||||||
if (options?.count) return `${key} (${options.count})`;
|
if (typeof options?.count === "number") return `${key} (${options.count})`;
|
||||||
if (options?.days) return `${key} (${options.days})`;
|
if (typeof options?.days === "number") return `${key} (${options.days})`;
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,35 @@
|
|||||||
// Core Types for MedAssist
|
// Core Types for MedAssist
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export type PackageType = "blister" | "bottle" | "tube" | "liquid_container";
|
export type { PackageProfile, PackageType } from "./package-profiles";
|
||||||
|
export {
|
||||||
|
allowsPillFormSelection,
|
||||||
|
getPackageProfile,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
PACKAGE_PROFILES,
|
||||||
|
PACKAGE_TYPES,
|
||||||
|
} from "./package-profiles";
|
||||||
|
|
||||||
|
import type { PackageType } from "./package-profiles";
|
||||||
|
import { isAmountBasedPackageType } from "./package-profiles";
|
||||||
|
|
||||||
// Common medication dose units
|
// Common medication dose units
|
||||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
|
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||||
|
|
||||||
|
export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
|
||||||
|
export type PillForm = "tablet" | "capsule";
|
||||||
|
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
||||||
|
export type PackageAmountUnit = "ml" | "g";
|
||||||
|
|
||||||
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
|
||||||
{ value: "mg", label: "mg" },
|
{ value: "mg", label: "mg" },
|
||||||
{ value: "g", label: "g" },
|
{ value: "g", label: "g" },
|
||||||
{ value: "mcg", label: "mcg (µg)" },
|
{ value: "mcg", label: "mcg (µg)" },
|
||||||
{ value: "ml", label: "ml" },
|
{ value: "ml", label: "ml" },
|
||||||
|
{ value: "units", label: "units" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Blister = {
|
export type Blister = {
|
||||||
@@ -50,7 +69,14 @@ export type Medication = {
|
|||||||
lastStockCorrectionAt?: string | null;
|
lastStockCorrectionAt?: string | null;
|
||||||
pillWeightMg?: number | null;
|
pillWeightMg?: number | null;
|
||||||
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
|
medicationForm?: MedicationForm | null;
|
||||||
|
pillForm?: PillForm | null;
|
||||||
|
lifecycleCategory?: LifecycleCategory | null;
|
||||||
|
packageAmountValue?: number | null;
|
||||||
|
packageAmountUnit?: PackageAmountUnit | null;
|
||||||
medicationStartDate?: string | null;
|
medicationStartDate?: string | null;
|
||||||
|
medicationEndDate?: string | null;
|
||||||
|
autoMarkObsoleteAfterEndDate?: boolean;
|
||||||
blisters: Blister[]; // Legacy array format
|
blisters: Blister[]; // Legacy array format
|
||||||
intakes?: Intake[]; // New intake format with per-intake takenBy
|
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -114,15 +140,22 @@ export type FormState = {
|
|||||||
name: string;
|
name: string;
|
||||||
genericName: string;
|
genericName: string;
|
||||||
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
|
takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
|
||||||
|
medicationForm: MedicationForm;
|
||||||
|
pillForm: PillForm;
|
||||||
|
lifecycleCategory: LifecycleCategory;
|
||||||
packageType: PackageType;
|
packageType: PackageType;
|
||||||
packCount: string;
|
packCount: string;
|
||||||
blistersPerPack: string;
|
blistersPerPack: string;
|
||||||
pillsPerBlister: string;
|
pillsPerBlister: string;
|
||||||
|
packageAmountValue: string;
|
||||||
|
packageAmountUnit: PackageAmountUnit;
|
||||||
totalPills: string; // For bottle type: total capacity
|
totalPills: string; // For bottle type: total capacity
|
||||||
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
||||||
pillWeightMg: string;
|
pillWeightMg: string;
|
||||||
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||||
medicationStartDate: string;
|
medicationStartDate: string;
|
||||||
|
medicationEndDate: string;
|
||||||
|
autoMarkObsoleteAfterEndDate: boolean;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
prescriptionEnabled: boolean;
|
prescriptionEnabled: boolean;
|
||||||
@@ -260,7 +293,7 @@ type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlist
|
|||||||
export function getMedTotal(med: MedLike): number {
|
export function getMedTotal(med: MedLike): number {
|
||||||
// Amount-based package types store their current base stock directly
|
// Amount-based package types store their current base stock directly
|
||||||
// in totalPills (fallback looseTablets for legacy rows).
|
// in totalPills (fallback looseTablets for legacy rows).
|
||||||
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
const baseStock = med.totalPills ?? med.looseTablets;
|
const baseStock = med.totalPills ?? med.looseTablets;
|
||||||
return baseStock + (med.stockAdjustment ?? 0);
|
return baseStock + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
@@ -271,7 +304,7 @@ 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 {
|
||||||
// Amount-based package types use totalPills as base capacity
|
// Amount-based package types use totalPills as base capacity
|
||||||
if (med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container") {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
return med.totalPills ?? med.looseTablets;
|
return med.totalPills ?? med.looseTablets;
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||||
|
|
||||||
|
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export type PackageProfile = {
|
||||||
|
value: PackageType;
|
||||||
|
labelKey: string;
|
||||||
|
amountBased: boolean;
|
||||||
|
plannerUnitKind: "pills" | "ml" | "units";
|
||||||
|
allowsPillFormSelection: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PACKAGE_PROFILES: PackageProfile[] = [
|
||||||
|
{
|
||||||
|
value: "blister",
|
||||||
|
labelKey: "form.packageTypeBlister",
|
||||||
|
amountBased: false,
|
||||||
|
plannerUnitKind: "pills",
|
||||||
|
allowsPillFormSelection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bottle",
|
||||||
|
labelKey: "form.packageTypeBottle",
|
||||||
|
amountBased: true,
|
||||||
|
plannerUnitKind: "pills",
|
||||||
|
allowsPillFormSelection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "tube",
|
||||||
|
labelKey: "form.packageTypeTube",
|
||||||
|
amountBased: true,
|
||||||
|
plannerUnitKind: "units",
|
||||||
|
allowsPillFormSelection: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "liquid_container",
|
||||||
|
labelKey: "form.packageTypeLiquidContainer",
|
||||||
|
amountBased: true,
|
||||||
|
plannerUnitKind: "ml",
|
||||||
|
allowsPillFormSelection: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
|
||||||
|
const PROFILE_BY_TYPE = new Map(PACKAGE_PROFILES.map((profile) => [profile.value, profile] as const));
|
||||||
|
|
||||||
|
export function normalizePackageType(packageType?: string | null): PackageType {
|
||||||
|
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
|
||||||
|
return packageType as PackageType;
|
||||||
|
}
|
||||||
|
return "blister";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageProfile(packageType?: string | null): PackageProfile {
|
||||||
|
return PROFILE_BY_TYPE.get(normalizePackageType(packageType)) ?? PACKAGE_PROFILES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTubePackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "tube";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||||
|
return getPackageProfile(packageType).amountBased;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowsPillFormSelection(packageType?: string | null): boolean {
|
||||||
|
return getPackageProfile(packageType).allowsPillFormSelection;
|
||||||
|
}
|
||||||
@@ -12,14 +12,14 @@ import type {
|
|||||||
StockStatus,
|
StockStatus,
|
||||||
StockThresholds,
|
StockThresholds,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getMedDisplayName, getMedTotal } from "../types";
|
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
|
|
||||||
function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
||||||
const usage = Number(intake.usage);
|
const usage = Number(intake.usage);
|
||||||
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
if (med.packageType === "tube") return 0;
|
if (isTubePackageType(med.packageType)) return 0;
|
||||||
|
|
||||||
const isLiquidStock = med.packageType === "liquid_container" || med.medicationForm === "liquid";
|
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
|
||||||
if (!isLiquidStock) return usage;
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
if (intake.intakeUnit === "tsp") return usage * 5;
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
@@ -344,7 +344,7 @@ export function getStockStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tube has no stock reminder semantics.
|
// Tube has no stock reminder semantics.
|
||||||
if (packageType === "tube") {
|
if (isTubePackageType(packageType)) {
|
||||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ export function getStockStatus(
|
|||||||
return { level: "normal", className: "success", label: "status.noSchedule" };
|
return { level: "normal", className: "success", label: "status.noSchedule" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packageType === "liquid_container") {
|
if (isLiquidContainerPackageType(packageType)) {
|
||||||
const liquidThresholds = getLiquidDerivedThresholds(thresholds.criticalStockDays);
|
const liquidThresholds = getLiquidDerivedThresholds(thresholds.criticalStockDays);
|
||||||
if (daysLeft <= liquidThresholds.criticalDays) {
|
if (daysLeft <= liquidThresholds.criticalDays) {
|
||||||
return { level: "critical", className: "danger", label: "status.criticalStock" };
|
return { level: "critical", className: "danger", label: "status.criticalStock" };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
|
import { isAmountBasedPackageType } from "../types";
|
||||||
|
|
||||||
export type BlisterStockSplit = {
|
export type BlisterStockSplit = {
|
||||||
fullBlisters: number;
|
fullBlisters: number;
|
||||||
@@ -34,10 +35,9 @@ export function splitCurrentBlisterStock(
|
|||||||
* Convenience helper when medication object already contains stock fields.
|
* Convenience helper when medication object already contains stock fields.
|
||||||
*/
|
*/
|
||||||
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
||||||
const total =
|
const total = isAmountBasedPackageType(med.packageType)
|
||||||
med.packageType === "bottle" || med.packageType === "tube" || med.packageType === "liquid_container"
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
|
|
||||||
return splitCurrentBlisterStock(total, med.pillsPerBlister, med.looseTablets);
|
return splitCurrentBlisterStock(total, med.pillsPerBlister, med.looseTablets);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+17
-34
@@ -8,7 +8,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
@@ -439,19 +439,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lint-staged": {
|
"node_modules/lint-staged": {
|
||||||
"version": "16.2.7",
|
"version": "16.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.1.tgz",
|
||||||
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
|
"integrity": "sha512-bqvvquXzFBAlSbluugR4KXAe4XnO/QZcKVszpkBtqLWa2KEiVy8n6Xp38OeUbv/gOJOX4Vo9u5pFt/ADvbm42Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.3",
|
||||||
"listr2": "^9.0.5",
|
"listr2": "^9.0.5",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"nano-spawn": "^2.0.0",
|
|
||||||
"pidtree": "^0.6.0",
|
|
||||||
"string-argv": "^0.3.2",
|
"string-argv": "^0.3.2",
|
||||||
"yaml": "^2.8.1"
|
"tinyexec": "^1.0.2",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"lint-staged": "bin/lint-staged.js"
|
"lint-staged": "bin/lint-staged.js"
|
||||||
@@ -541,19 +540,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nano-spawn": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.17"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||||
@@ -570,19 +556,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pidtree": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"pidtree": "bin/pidtree.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -680,6 +653,16 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.2.7"
|
"lint-staged": "^16.3.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"backend/src/**/*.ts": [
|
"backend/src/**/*.ts": [
|
||||||
|
|||||||
Reference in New Issue
Block a user