Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f670a6355f | |||
| 3cdb38055d | |||
| 39c19ab2fe | |||
| 8372b7ec27 | |||
| b32ec9b21b | |||
| 60bef957de | |||
| 8e2d7e74d2 | |||
| 5382669ffe | |||
| 7059c25f1c |
@@ -494,6 +494,12 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
|||||||
|
|
||||||
All three labels trigger the `add-to-project.yml` workflow, which automatically adds the issue to the Project board.
|
All three labels trigger the `add-to-project.yml` workflow, which automatically adds the issue to the Project board.
|
||||||
|
|
||||||
|
### Weekly Triage Report Hygiene
|
||||||
|
|
||||||
|
- There must never be more than one open `Weekly Triage Report - YYYY-MM-DD` issue at the same time.
|
||||||
|
- Before a new weekly triage report issue is created, close any older open weekly triage report issue and leave a short closing comment.
|
||||||
|
- If automation creates a new weekly report without closing the old one first, treat that as workflow drift and fix the workflow or close the stale report immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Complete Workflow Summary
|
## Complete Workflow Summary
|
||||||
|
|||||||
@@ -68,6 +68,36 @@ jobs:
|
|||||||
const title = `${{ steps.summary.outputs.title }}`;
|
const title = `${{ steps.summary.outputs.title }}`;
|
||||||
const body = `${{ steps.summary.outputs.body }}`;
|
const body = `${{ steps.summary.outputs.body }}`;
|
||||||
|
|
||||||
|
const existingReports = await github.paginate(github.rest.issues.listForRepo, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
state: 'open',
|
||||||
|
labels: 'triage',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const issue of existingReports) {
|
||||||
|
if (issue.pull_request) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.title.startsWith('Weekly Triage Report - ') && issue.title !== title) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: 'Closing this older weekly triage report before publishing the next one so only one weekly report issue stays open at a time.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await github.rest.issues.create({
|
await github.rest.issues.create({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-833%2F833-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-875%2F875-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -120,7 +120,7 @@ Share your medication schedule with others via a public link.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Medication Setup
|
### Medication Setup
|
||||||
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`
|
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them
|
||||||
- Explicit review-and-apply flow with low-risk suggestions only
|
- Explicit review-and-apply flow with low-risk suggestions only
|
||||||
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
|
||||||
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
|
||||||
|
|||||||
Generated
+95
-108
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
@@ -18,23 +18,23 @@
|
|||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.2",
|
||||||
"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.8.2",
|
"fastify": "^5.8.2",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.3",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
@@ -103,9 +103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
|
||||||
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
|
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -119,20 +119,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.4.7",
|
"@biomejs/cli-darwin-arm64": "2.4.8",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.7",
|
"@biomejs/cli-darwin-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.7",
|
"@biomejs/cli-linux-arm64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.7",
|
"@biomejs/cli-linux-arm64-musl": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64": "2.4.7",
|
"@biomejs/cli-linux-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.7",
|
"@biomejs/cli-linux-x64-musl": "2.4.8",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.7",
|
"@biomejs/cli-win32-arm64": "2.4.8",
|
||||||
"@biomejs/cli-win32-x64": "2.4.7"
|
"@biomejs/cli-win32-x64": "2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
|
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -147,9 +147,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
|
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -164,9 +164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
|
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -181,9 +181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
|
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -198,9 +198,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
|
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -215,9 +215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
|
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -232,9 +232,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
|
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -249,9 +249,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
|
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2086,31 +2086,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/client": {
|
"node_modules/@libsql/client": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.2",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.2.tgz",
|
||||||
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
|
"integrity": "sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/core": "^0.17.0",
|
"@libsql/core": "^0.17.2",
|
||||||
"@libsql/hrana-client": "^0.9.0",
|
"@libsql/hrana-client": "^0.9.0",
|
||||||
"js-base64": "^3.7.5",
|
"js-base64": "^3.7.5",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.28",
|
||||||
"promise-limit": "^2.7.0"
|
"promise-limit": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/core": {
|
"node_modules/@libsql/core": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.2",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.2.tgz",
|
||||||
"integrity": "sha512-hnZRnJHiS+nrhHKLGYPoJbc78FE903MSDrFJTbftxo+e52X+E0Y0fHOCVYsKWcg6XgB7BbJYUrz/xEkVTSaipw==",
|
"integrity": "sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-base64": "^3.7.5"
|
"js-base64": "^3.7.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/darwin-arm64": {
|
"node_modules/@libsql/darwin-arm64": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.28.tgz",
|
||||||
"integrity": "sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==",
|
"integrity": "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2121,9 +2121,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/darwin-x64": {
|
"node_modules/@libsql/darwin-x64": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.28.tgz",
|
||||||
"integrity": "sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==",
|
"integrity": "sha512-m1hGkQm8A+CjZmR9D5G3zi36na7GXGJomsMbHwOFiCUYPjqRReD5KZ2HZ/qEAV6U/66xPdDDCuqDB8MzNhiwxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2156,9 +2156,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-arm-gnueabihf": {
|
"node_modules/@libsql/linux-arm-gnueabihf": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.28.tgz",
|
||||||
"integrity": "sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==",
|
"integrity": "sha512-D22yQotJkLcYxrwYP9ukoqbpA5hK7pHmho9jagCM/ij7UwjWJPAY2d2SmEndpJs/SueaGy1xuiUQFec4R7VebQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2169,9 +2169,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-arm-musleabihf": {
|
"node_modules/@libsql/linux-arm-musleabihf": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.28.tgz",
|
||||||
"integrity": "sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==",
|
"integrity": "sha512-Z/aSb2WzZm7TYn/FEqefoN2sJoDhMtCjV8aHw55ibck6mdLLPGMYXxTyWn5U/OZbqD+wiM7eUgdsG20uEzxEoQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2182,9 +2182,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-arm64-gnu": {
|
"node_modules/@libsql/linux-arm64-gnu": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.28.tgz",
|
||||||
"integrity": "sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==",
|
"integrity": "sha512-gQGJgmUBdk3qm8rDwvFujzTWipLE4ZNP9fgcdVabVBFmD38wLOU5aZ4F3BHrL1ZWdvsrC8mrtnCTKEGuYHDZIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2195,9 +2195,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-arm64-musl": {
|
"node_modules/@libsql/linux-arm64-musl": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.28.tgz",
|
||||||
"integrity": "sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==",
|
"integrity": "sha512-zLlgKyG96DKJ4skFtubHbWuWRUW8YpcjHVyKyJJDIp2USPQKLXfB+rT06OSQIS90Bm3dbfU+9rAlNX0ua0cSvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2208,9 +2208,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-x64-gnu": {
|
"node_modules/@libsql/linux-x64-gnu": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.28.tgz",
|
||||||
"integrity": "sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==",
|
"integrity": "sha512-ra+fk6FmTl8ma4opxcTJ8JIt3KrSr+TrFCJtgccfg+7HDdGiE5Ys6jIJMqYuYG61Mv40z3lPZxRivBK5sP9o/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2221,9 +2221,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/linux-x64-musl": {
|
"node_modules/@libsql/linux-x64-musl": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.28.tgz",
|
||||||
"integrity": "sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==",
|
"integrity": "sha512-XXl7lHsZEY8szhfMWoe0tFzKXv52nlDt0kckMmtYb97AkKB0bIcxbgx5zTHGyoXLMMhLvEo33OR7NHvjdDyvjw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2234,9 +2234,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@libsql/win32-x64-msvc": {
|
"node_modules/@libsql/win32-x64-msvc": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.28.tgz",
|
||||||
"integrity": "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==",
|
"integrity": "sha512-KLB4TQKkRdki9Ugbz+X986a1F7IaZUZbPuTfPNFi7slTT+biSw0b/LPJ0tCk7EHyo5QmN8tZ1XLZwI7GgUBsfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3297,16 +3297,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.9",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
|
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@drizzle-team/brocli": "^0.10.2",
|
"@drizzle-team/brocli": "^0.10.2",
|
||||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||||
"esbuild": "^0.25.4",
|
"esbuild": "^0.25.4",
|
||||||
"esbuild-register": "^3.5.0"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"drizzle-kit": "bin.cjs"
|
"drizzle-kit": "bin.cjs"
|
||||||
@@ -4053,19 +4053,6 @@
|
|||||||
"@esbuild/win32-x64": "0.27.2"
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild-register": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.3.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"esbuild": ">=0.12 <1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -4758,9 +4745,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/libsql": {
|
"node_modules/libsql": {
|
||||||
"version": "0.5.22",
|
"version": "0.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.28.tgz",
|
||||||
"integrity": "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==",
|
"integrity": "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64",
|
"x64",
|
||||||
"arm64",
|
"arm64",
|
||||||
@@ -4778,15 +4765,15 @@
|
|||||||
"detect-libc": "2.0.2"
|
"detect-libc": "2.0.2"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@libsql/darwin-arm64": "0.5.22",
|
"@libsql/darwin-arm64": "0.5.28",
|
||||||
"@libsql/darwin-x64": "0.5.22",
|
"@libsql/darwin-x64": "0.5.28",
|
||||||
"@libsql/linux-arm-gnueabihf": "0.5.22",
|
"@libsql/linux-arm-gnueabihf": "0.5.28",
|
||||||
"@libsql/linux-arm-musleabihf": "0.5.22",
|
"@libsql/linux-arm-musleabihf": "0.5.28",
|
||||||
"@libsql/linux-arm64-gnu": "0.5.22",
|
"@libsql/linux-arm64-gnu": "0.5.28",
|
||||||
"@libsql/linux-arm64-musl": "0.5.22",
|
"@libsql/linux-arm64-musl": "0.5.28",
|
||||||
"@libsql/linux-x64-gnu": "0.5.22",
|
"@libsql/linux-x64-gnu": "0.5.28",
|
||||||
"@libsql/linux-x64-musl": "0.5.22",
|
"@libsql/linux-x64-musl": "0.5.28",
|
||||||
"@libsql/win32-x64-msvc": "0.5.22"
|
"@libsql/win32-x64-msvc": "0.5.28"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
@@ -5343,9 +5330,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz",
|
||||||
"integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
|
"integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,23 +27,23 @@
|
|||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.5",
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.2",
|
||||||
"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.8.2",
|
"fastify": "^5.8.2",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.3",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ const strengthOptionSchema = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const packageOptionSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
label: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
|
||||||
|
packCount: { type: "integer", minimum: 1 },
|
||||||
|
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
|
||||||
|
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
|
||||||
|
totalPills: { type: "integer", minimum: 0, nullable: true },
|
||||||
|
looseTablets: { type: "integer", minimum: 0, nullable: true },
|
||||||
|
packageAmountValue: { type: "integer", minimum: 1, nullable: true },
|
||||||
|
packageAmountUnit: {
|
||||||
|
anyOf: [{ type: "string", enum: ["ml", "g"] }, { type: "null" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
const searchResponseSchema = {
|
const searchResponseSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -95,6 +113,7 @@ const searchResponseSchema = {
|
|||||||
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||||
authorisationDate: { type: "string", nullable: true },
|
authorisationDate: { type: "string", nullable: true },
|
||||||
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||||
|
packageOptions: { type: "array", items: packageOptionSchema },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -127,6 +146,7 @@ const enrichResponseSchema = {
|
|||||||
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||||
},
|
},
|
||||||
strengthOptions: { type: "array", items: strengthOptionSchema },
|
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||||
|
packageOptions: { type: "array", items: packageOptionSchema },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ const stockAdjustmentBodySchema = {
|
|||||||
looseTablets: { type: "integer", minimum: 0 },
|
looseTablets: { type: "integer", minimum: 0 },
|
||||||
totalPills: { type: "integer", minimum: 0 },
|
totalPills: { type: "integer", minimum: 0 },
|
||||||
packageAmountValue: { type: "integer", minimum: 0 },
|
packageAmountValue: { type: "integer", minimum: 0 },
|
||||||
packCount: { type: "integer", minimum: 1 },
|
packCount: { type: "integer", minimum: 0 },
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
stockAdjustment: -2,
|
stockAdjustment: -2,
|
||||||
@@ -1238,8 +1238,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
) {
|
) {
|
||||||
return reply.badRequest("packageAmountValue must be a non-negative integer");
|
return reply.badRequest("packageAmountValue must be a non-negative integer");
|
||||||
}
|
}
|
||||||
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) {
|
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 0)) {
|
||||||
return reply.badRequest("packCount must be an integer >= 1");
|
return reply.badRequest("packCount must be a non-negative integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateFields: {
|
const updateFields: {
|
||||||
@@ -1258,12 +1258,16 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const packageType = normalizePackageType(existing.packageType);
|
const packageType = normalizePackageType(existing.packageType);
|
||||||
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
|
const allowsBottleCapacityUpdate = packageType === "bottle";
|
||||||
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;
|
||||||
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||||
if (packCount !== undefined) updateFields.packCount = packCount;
|
|
||||||
}
|
}
|
||||||
|
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
||||||
|
updateFields.totalPills = totalPills;
|
||||||
|
}
|
||||||
|
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||||
if (looseTablets !== undefined) {
|
if (looseTablets !== undefined) {
|
||||||
updateFields.looseTablets = looseTablets;
|
updateFields.looseTablets = looseTablets;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,18 +197,21 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
|
const refillBaselineAt = new Date();
|
||||||
const updatePayload: {
|
const updatePayload: {
|
||||||
packCount: number;
|
packCount: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
packageAmountValue?: number;
|
packageAmountValue?: number;
|
||||||
prescriptionRemainingRefills: number | null;
|
prescriptionRemainingRefills: number | null;
|
||||||
|
lastStockCorrectionAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} = {
|
} = {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
updatedAt: new Date(),
|
lastStockCorrectionAt: refillBaselineAt,
|
||||||
|
updatedAt: refillBaselineAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCountBasedAmountPackage) {
|
if (isCountBasedAmountPackage) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { PackageType } from "../utils/package-profiles.js";
|
||||||
|
|
||||||
const EMA_MEDICINES_URL =
|
const EMA_MEDICINES_URL =
|
||||||
"https://www.ema.europa.eu/en/documents/report/medicines-output-medicines_json-report_en.json";
|
"https://www.ema.europa.eu/en/documents/report/medicines-output-medicines_json-report_en.json";
|
||||||
@@ -40,6 +41,7 @@ export type MedicationEnrichmentSearchResult = {
|
|||||||
genericStatus: "generic" | "original" | "unknown";
|
genericStatus: "generic" | "original" | "unknown";
|
||||||
authorisationDate: string | null;
|
authorisationDate: string | null;
|
||||||
source: MedicationEnrichmentSearchSource;
|
source: MedicationEnrichmentSearchSource;
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationEnrichmentStrengthOption = {
|
export type MedicationEnrichmentStrengthOption = {
|
||||||
@@ -48,6 +50,19 @@ export type MedicationEnrichmentStrengthOption = {
|
|||||||
doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | null;
|
doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationEnrichmentPackageOption = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
packageType: PackageType;
|
||||||
|
packCount: number;
|
||||||
|
blistersPerPack: number | null;
|
||||||
|
pillsPerBlister: number | null;
|
||||||
|
totalPills: number | null;
|
||||||
|
looseTablets: number | null;
|
||||||
|
packageAmountValue: number | null;
|
||||||
|
packageAmountUnit: "ml" | "g" | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationEnrichmentSearchResponse = {
|
export type MedicationEnrichmentSearchResponse = {
|
||||||
query: string;
|
query: string;
|
||||||
normalizedQuery: string;
|
normalizedQuery: string;
|
||||||
@@ -77,6 +92,7 @@ export type MedicationEnrichmentEnrichResponse = {
|
|||||||
genericName: string | null;
|
genericName: string | null;
|
||||||
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
||||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
};
|
};
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched: boolean;
|
rxNormMatched: boolean;
|
||||||
@@ -161,6 +177,12 @@ type OpenFdaProduct = {
|
|||||||
dosage_form?: string;
|
dosage_form?: string;
|
||||||
marketing_start_date?: string;
|
marketing_start_date?: string;
|
||||||
active_ingredients?: OpenFdaActiveIngredient[];
|
active_ingredients?: OpenFdaActiveIngredient[];
|
||||||
|
packaging?: OpenFdaPackaging[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenFdaPackaging = {
|
||||||
|
description?: string;
|
||||||
|
package_ndc?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenFdaResponse = {
|
type OpenFdaResponse = {
|
||||||
@@ -172,6 +194,14 @@ type OpenFdaEnrichment = {
|
|||||||
genericName: string | null;
|
genericName: string | null;
|
||||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null;
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedOpenFdaPackagingSegment = {
|
||||||
|
quantity: number;
|
||||||
|
itemText: string;
|
||||||
|
containerCount: number;
|
||||||
|
containerText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultLogger: MedicationEnrichmentLogger = {
|
const defaultLogger: MedicationEnrichmentLogger = {
|
||||||
@@ -436,6 +466,7 @@ function compareSearchResults(
|
|||||||
right: MedicationEnrichmentSearchResult & { score: number }
|
right: MedicationEnrichmentSearchResult & { score: number }
|
||||||
): number {
|
): number {
|
||||||
return (
|
return (
|
||||||
|
right.packageOptions.length - left.packageOptions.length ||
|
||||||
getSearchSourcePriority(left.source) - getSearchSourcePriority(right.source) ||
|
getSearchSourcePriority(left.source) - getSearchSourcePriority(right.source) ||
|
||||||
right.score - left.score ||
|
right.score - left.score ||
|
||||||
left.name.localeCompare(right.name)
|
left.name.localeCompare(right.name)
|
||||||
@@ -474,6 +505,7 @@ function collectEmaSearchResults(
|
|||||||
genericStatus: entry.genericStatus,
|
genericStatus: entry.genericStatus,
|
||||||
authorisationDate: entry.authorisationDate,
|
authorisationDate: entry.authorisationDate,
|
||||||
source: "ema",
|
source: "ema",
|
||||||
|
packageOptions: [],
|
||||||
score: bestMatch.score,
|
score: bestMatch.score,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -623,6 +655,7 @@ function buildRxNormSearchResult(property: RxNormDrugConceptProperty): Medicatio
|
|||||||
genericStatus: "unknown",
|
genericStatus: "unknown",
|
||||||
authorisationDate: null,
|
authorisationDate: null,
|
||||||
source: "rxnorm",
|
source: "rxnorm",
|
||||||
|
packageOptions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +782,165 @@ function normalizeOpenFdaName(value: unknown): string | null {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOpenFdaPackagingText(value: string): string {
|
||||||
|
return value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenFdaPackagingSegment(segment: string): ParsedOpenFdaPackagingSegment | null {
|
||||||
|
const sanitized = sanitizeText(segment);
|
||||||
|
if (!sanitized) return null;
|
||||||
|
const match = /^(\d+(?:[.,]\d+)?)\s+(.+?)\s+in\s+(\d+(?:[.,]\d+)?)\s+(.+)$/i.exec(sanitized);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const quantity = Number(match[1].replace(",", "."));
|
||||||
|
const containerCount = Number(match[3].replace(",", "."));
|
||||||
|
if (!Number.isFinite(quantity) || quantity <= 0 || !Number.isFinite(containerCount) || containerCount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quantity,
|
||||||
|
itemText: match[2].trim(),
|
||||||
|
containerCount,
|
||||||
|
containerText: match[4].trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenFdaBlisterLikeContainer(containerText: string): boolean {
|
||||||
|
const normalized = normalizeOpenFdaPackagingText(containerText);
|
||||||
|
return (
|
||||||
|
normalized.includes("BLISTER") ||
|
||||||
|
normalized.includes("POUCH") ||
|
||||||
|
normalized.includes("STRIP") ||
|
||||||
|
normalized.includes("SACHET")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenFdaBottleLikeContainer(containerText: string): boolean {
|
||||||
|
const normalized = normalizeOpenFdaPackagingText(containerText);
|
||||||
|
return normalized.includes("BOTTLE") || normalized.includes("JAR") || normalized.includes("VIAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenFdaSolidUnit(itemText: string): boolean {
|
||||||
|
const normalized = normalizeOpenFdaPackagingText(itemText);
|
||||||
|
return (
|
||||||
|
normalized.includes("TABLET") ||
|
||||||
|
normalized.includes("CAPSULE") ||
|
||||||
|
normalized.includes("CAPLET") ||
|
||||||
|
normalized.includes("SOFTGEL") ||
|
||||||
|
normalized.includes("LOZENGE")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenFdaAmountUnit(itemText: string): "ml" | "g" | null {
|
||||||
|
const normalized = normalizeOpenFdaPackagingText(itemText);
|
||||||
|
if (normalized.includes("ML")) return "ml";
|
||||||
|
if (normalized === "G" || normalized.startsWith("G ") || normalized.includes("GRAM")) return "g";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveInteger(value: number): number | null {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] {
|
||||||
|
const byKey = new Map<string, MedicationEnrichmentPackageOption>();
|
||||||
|
for (const option of options) {
|
||||||
|
const key = JSON.stringify([
|
||||||
|
option.description,
|
||||||
|
option.packageType,
|
||||||
|
option.packCount,
|
||||||
|
option.blistersPerPack,
|
||||||
|
option.pillsPerBlister,
|
||||||
|
option.totalPills,
|
||||||
|
option.packageAmountValue,
|
||||||
|
option.packageAmountUnit,
|
||||||
|
]);
|
||||||
|
if (!byKey.has(key)) {
|
||||||
|
byKey.set(key, option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenFdaPackageOptions(product: OpenFdaProduct): MedicationEnrichmentPackageOption[] {
|
||||||
|
return uniquePackageOptions(
|
||||||
|
(product.packaging ?? [])
|
||||||
|
.map((entry): MedicationEnrichmentPackageOption | null => {
|
||||||
|
const description = sanitizeText(entry.description);
|
||||||
|
if (!description) return null;
|
||||||
|
|
||||||
|
const segments = description.split(/\s*\/\s*/).filter((value) => value.trim().length > 0);
|
||||||
|
const outerSegment = segments.length > 1 ? parseOpenFdaPackagingSegment(segments[0] ?? "") : null;
|
||||||
|
const primarySegment = parseOpenFdaPackagingSegment(segments[segments.length - 1] ?? "");
|
||||||
|
if (!primarySegment) return null;
|
||||||
|
|
||||||
|
const packCount = toPositiveInteger(outerSegment?.quantity ?? 1) ?? 1;
|
||||||
|
const packageAmountUnit = getOpenFdaAmountUnit(primarySegment.itemText);
|
||||||
|
if (packageAmountUnit) {
|
||||||
|
const packageAmountValue = toPositiveInteger(primarySegment.quantity);
|
||||||
|
if (packageAmountValue === null) return null;
|
||||||
|
const totalAmount = packCount * packageAmountValue;
|
||||||
|
return {
|
||||||
|
label: description,
|
||||||
|
description,
|
||||||
|
packageType: packageAmountUnit === "g" ? "tube" : "liquid_container",
|
||||||
|
packCount,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills: totalAmount,
|
||||||
|
looseTablets: totalAmount,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
|
} satisfies MedicationEnrichmentPackageOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpenFdaSolidUnit(primarySegment.itemText)) return null;
|
||||||
|
const pillsPerUnit = toPositiveInteger(primarySegment.quantity);
|
||||||
|
if (pillsPerUnit === null) return null;
|
||||||
|
|
||||||
|
if (isOpenFdaBlisterLikeContainer(primarySegment.containerText)) {
|
||||||
|
return {
|
||||||
|
label: description,
|
||||||
|
description,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: packCount,
|
||||||
|
pillsPerBlister: pillsPerUnit,
|
||||||
|
totalPills: packCount * pillsPerUnit,
|
||||||
|
looseTablets: 0,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
} satisfies MedicationEnrichmentPackageOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpenFdaBottleLikeContainer(primarySegment.containerText) || outerSegment === null) {
|
||||||
|
const totalPills = packCount * pillsPerUnit;
|
||||||
|
return {
|
||||||
|
label: description,
|
||||||
|
description,
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills,
|
||||||
|
looseTablets: totalPills,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
} satisfies MedicationEnrichmentPackageOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((value): value is MedicationEnrichmentPackageOption => value !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichmentSearchResult | null {
|
function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichmentSearchResult | null {
|
||||||
const code = sanitizeText(product.product_ndc) ?? sanitizeText(product.product_id);
|
const code = sanitizeText(product.product_ndc) ?? sanitizeText(product.product_id);
|
||||||
const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name);
|
const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name);
|
||||||
@@ -773,6 +965,7 @@ function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichment
|
|||||||
genericStatus: "unknown",
|
genericStatus: "unknown",
|
||||||
authorisationDate: parseCompactDate(product.marketing_start_date),
|
authorisationDate: parseCompactDate(product.marketing_start_date),
|
||||||
source: "openfda",
|
source: "openfda",
|
||||||
|
packageOptions: buildOpenFdaPackageOptions(product),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +1045,7 @@ function buildOpenFdaEnrichment(product: OpenFdaProduct): OpenFdaEnrichment | nu
|
|||||||
genericName,
|
genericName,
|
||||||
strengthOptions: buildOpenFdaStrengthOptions(product),
|
strengthOptions: buildOpenFdaStrengthOptions(product),
|
||||||
medicationForm: product.dosage_form ? deriveMedicationFormFromName(product.dosage_form) : null,
|
medicationForm: product.dosage_form ? deriveMedicationFormFromName(product.dosage_form) : null,
|
||||||
|
packageOptions: buildOpenFdaPackageOptions(product),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1228,9 @@ export async function enrichMedicationSelection(
|
|||||||
);
|
);
|
||||||
openFdaMatched =
|
openFdaMatched =
|
||||||
openFdaEnrichment !== null &&
|
openFdaEnrichment !== null &&
|
||||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
(openFdaEnrichment.medicationForm !== null ||
|
||||||
|
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||||
|
openFdaEnrichment.packageOptions.length > 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
partial = true;
|
partial = true;
|
||||||
note = note ?? "Returned EMA enrichment without secondary-source suggestions.";
|
note = note ?? "Returned EMA enrichment without secondary-source suggestions.";
|
||||||
@@ -1061,6 +1257,7 @@ export async function enrichMedicationSelection(
|
|||||||
rxNormEnrichment?.strengthOptions ?? [],
|
rxNormEnrichment?.strengthOptions ?? [],
|
||||||
openFdaEnrichment?.strengthOptions ?? []
|
openFdaEnrichment?.strengthOptions ?? []
|
||||||
),
|
),
|
||||||
|
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched,
|
rxNormMatched,
|
||||||
@@ -1099,7 +1296,9 @@ export async function enrichMedicationSelection(
|
|||||||
openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery(selection.genericName ?? selection.name);
|
openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery(selection.genericName ?? selection.name);
|
||||||
openFdaMatched =
|
openFdaMatched =
|
||||||
openFdaEnrichment !== null &&
|
openFdaEnrichment !== null &&
|
||||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
(openFdaEnrichment.medicationForm !== null ||
|
||||||
|
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||||
|
openFdaEnrichment.packageOptions.length > 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
partial = true;
|
partial = true;
|
||||||
note = note ?? "Returned RxNorm enrichment without openFDA suggestions.";
|
note = note ?? "Returned RxNorm enrichment without openFDA suggestions.";
|
||||||
@@ -1126,6 +1325,7 @@ export async function enrichMedicationSelection(
|
|||||||
rxNormEnrichment?.strengthOptions ?? [],
|
rxNormEnrichment?.strengthOptions ?? [],
|
||||||
openFdaEnrichment?.strengthOptions ?? []
|
openFdaEnrichment?.strengthOptions ?? []
|
||||||
),
|
),
|
||||||
|
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched,
|
rxNormMatched,
|
||||||
@@ -1164,7 +1364,9 @@ export async function enrichMedicationSelection(
|
|||||||
openFdaEnrichment = buildOpenFdaEnrichment(product);
|
openFdaEnrichment = buildOpenFdaEnrichment(product);
|
||||||
openFdaMatched =
|
openFdaMatched =
|
||||||
openFdaEnrichment !== null &&
|
openFdaEnrichment !== null &&
|
||||||
(openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0);
|
(openFdaEnrichment.medicationForm !== null ||
|
||||||
|
openFdaEnrichment.strengthOptions.length > 0 ||
|
||||||
|
openFdaEnrichment.packageOptions.length > 0);
|
||||||
|
|
||||||
const openFdaGeneric = openFdaEnrichment?.genericName ?? request.genericName ?? request.name;
|
const openFdaGeneric = openFdaEnrichment?.genericName ?? request.genericName ?? request.name;
|
||||||
try {
|
try {
|
||||||
@@ -1197,6 +1399,7 @@ export async function enrichMedicationSelection(
|
|||||||
rxNormEnrichment?.strengthOptions ?? [],
|
rxNormEnrichment?.strengthOptions ?? [],
|
||||||
openFdaEnrichment?.strengthOptions ?? []
|
openFdaEnrichment?.strengthOptions ?? []
|
||||||
),
|
),
|
||||||
|
packageOptions: openFdaEnrichment?.packageOptions ?? [],
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched,
|
rxNormMatched,
|
||||||
|
|||||||
@@ -1867,6 +1867,133 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
|
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reset automatic stock baseline on refill so pre-refill dose history no longer reduces current stock", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Automatic Refill Baseline",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 14,
|
||||||
|
looseTablets: 0,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const preRefillDoseDateOnlyMs = new Date("2025-01-05T00:00:00.000Z").getTime();
|
||||||
|
const preRefillTakenAtMs = new Date("2025-01-05T10:00:00.000Z").getTime();
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-${preRefillDoseDateOnlyMs}`, preRefillTakenAtMs],
|
||||||
|
});
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
expect(refillResponse.json().newStock.packCount).toBe(2);
|
||||||
|
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const nextWeek = new Date();
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|
||||||
|
const usageResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: {
|
||||||
|
startDate: tomorrow.toISOString(),
|
||||||
|
endDate: nextWeek.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usageResponse.statusCode).toBe(200);
|
||||||
|
const med = usageResponse.json().find((item: Record<string, unknown>) => item.medicationId === medId);
|
||||||
|
expect(med).toBeDefined();
|
||||||
|
expect(med.totalPills).toBe(28);
|
||||||
|
expect(med.currentPills).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset manual stock baseline on refill for liquid_container packages before later dose tracking", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Manual Liquid Refill Baseline",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
packageAmountValue: 5,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 5,
|
||||||
|
looseTablets: 5,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const preRefillDoseDateOnlyMs = new Date("2025-01-05T00:00:00.000Z").getTime();
|
||||||
|
const preRefillTakenAtMs = new Date("2025-01-05T10:00:00.000Z").getTime();
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-${preRefillDoseDateOnlyMs}`, preRefillTakenAtMs],
|
||||||
|
});
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.loosePillsAdded).toBe(5);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(10);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||||
|
expect(med.totalPills).toBe(10);
|
||||||
|
expect(med.looseTablets).toBe(10);
|
||||||
|
|
||||||
|
const firstPostRefillDoseId = `${medId}-0-${new Date("2026-01-06T00:00:00.000Z").getTime()}`;
|
||||||
|
const firstDoseResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
payload: { doseId: firstPostRefillDoseId },
|
||||||
|
});
|
||||||
|
expect(firstDoseResponse.statusCode).toBe(200);
|
||||||
|
expect(firstDoseResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const secondPostRefillDoseId = `${medId}-0-${new Date("2026-01-07T00:00:00.000Z").getTime()}`;
|
||||||
|
const secondDoseResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
payload: { doseId: secondPostRefillDoseId },
|
||||||
|
});
|
||||||
|
expect(secondDoseResponse.statusCode).toBe(200);
|
||||||
|
expect(secondDoseResponse.json()).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("should decrement remaining refills and mark history when using prescription refill", async () => {
|
it("should decrement remaining refills and mark history when using prescription refill", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2134,6 +2261,187 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.updatedAt).toBeTruthy();
|
expect(data.updatedAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should accept packCount set to 0 in stock adjustment patch", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Pack Count Zero Patch Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 4,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(0);
|
||||||
|
expect(med.looseTablets).toBe(0);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist blister zero reset with packCount 0", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Blister Zero Reset Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 3,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 5,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(0);
|
||||||
|
expect(med.looseTablets).toBe(0);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Bottle Zero Reset Med",
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0, totalPills: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(0);
|
||||||
|
expect(med.looseTablets).toBe(0);
|
||||||
|
expect(med.totalPills).toBe(0);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: "liquid container",
|
||||||
|
payload: {
|
||||||
|
name: "Liquid Zero Reset Med",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 180,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 180,
|
||||||
|
looseTablets: 180,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "tube",
|
||||||
|
payload: {
|
||||||
|
name: "Tube Zero Reset Med",
|
||||||
|
medicationForm: "topical",
|
||||||
|
packageType: "tube",
|
||||||
|
doseUnit: "units",
|
||||||
|
packCount: 2,
|
||||||
|
packageAmountValue: 40,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 80,
|
||||||
|
looseTablets: 80,
|
||||||
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])("should persist $label zero reset with zeroed amount-base fields", async ({ payload }) => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 0,
|
||||||
|
looseTablets: 0,
|
||||||
|
totalPills: 0,
|
||||||
|
packageAmountValue: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(0);
|
||||||
|
expect(med.looseTablets).toBe(0);
|
||||||
|
expect(med.totalPills).toBe(0);
|
||||||
|
expect(med.packageAmountValue).toBe(0);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should persist stockAdjustment in GET /medications", async () => {
|
it("should persist stockAdjustment in GET /medications", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2853,26 +3161,83 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.medications[0].totalPills).toBe(65);
|
expect(data.medications[0].totalPills).toBe(65);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for bottle type", async () => {
|
it("should refill bottle stock from loose tablets without mutating explicit capacity", async () => {
|
||||||
|
const bottleWithExplicitCapacity = {
|
||||||
|
...bottleMedication,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
};
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
payload: bottleMedication,
|
payload: bottleWithExplicitCapacity,
|
||||||
});
|
});
|
||||||
const medId = createResponse.json().id;
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
// Refill bottle: only loosePillsAdded matters, packs should add 0 pills
|
// Refill bottle: only loosePillsAdded should affect current stock.
|
||||||
const refillResponse = await app.inject({
|
const refillResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/medications/${medId}/refill`,
|
url: `/medications/${medId}/refill`,
|
||||||
payload: { packsAdded: 0, loosePillsAdded: 30 },
|
payload: { packsAdded: 0, loosePillsAdded: 50 },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const data = refillResponse.json();
|
const data = refillResponse.json();
|
||||||
expect(data.refill.totalPillsAdded).toBe(30);
|
expect(data.refill.totalPillsAdded).toBe(50);
|
||||||
// newStock.totalPills should be looseTablets only (no blister math)
|
// Bottle current stock must be based on looseTablets, not configured capacity.
|
||||||
expect(data.newStock.totalPills).toBe(150); // 120 + 30
|
expect(data.newStock.totalPills).toBe(70);
|
||||||
|
expect(data.newStock.looseTablets).toBe(70);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(0);
|
||||||
|
expect(med.looseTablets).toBe(70);
|
||||||
|
// Persisted bottle capacity must remain unchanged on later GET /medications.
|
||||||
|
expect(med.totalPills).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use one prescription refill for bottle package refills and ignore pack count", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...bottleMedication,
|
||||||
|
prescriptionEnabled: true,
|
||||||
|
prescriptionAuthorizedRefills: 3,
|
||||||
|
prescriptionRemainingRefills: 2,
|
||||||
|
prescriptionLowRefillThreshold: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 3, loosePillsAdded: 30, usePrescription: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.packsAdded).toBe(0);
|
||||||
|
expect(refillData.refill.loosePillsAdded).toBe(30);
|
||||||
|
expect(refillData.prescription.used).toBe(true);
|
||||||
|
expect(refillData.prescription.remainingRefills).toBe(1);
|
||||||
|
expect(refillData.newStock.packCount).toBe(0);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(150);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
|
packsAdded: 0,
|
||||||
|
loosePillsAdded: 30,
|
||||||
|
usedPrescription: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||||||
@@ -2893,6 +3258,16 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const data = refillResponse.json();
|
const data = refillResponse.json();
|
||||||
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
|
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
|
||||||
|
expect(data.newStock.packCount).toBe(3);
|
||||||
|
expect(data.newStock.looseTablets).toBe(10);
|
||||||
|
expect(data.newStock.totalPills).toBe(100);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(3);
|
||||||
|
expect(med.looseTablets).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
@@ -2931,6 +3306,85 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.looseTablets).toBe(360);
|
expect(med.looseTablets).toBe(360);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "liquid_container",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 180,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 180,
|
||||||
|
looseTablets: 180,
|
||||||
|
prescriptionEnabled: true,
|
||||||
|
prescriptionAuthorizedRefills: 3,
|
||||||
|
prescriptionRemainingRefills: 2,
|
||||||
|
prescriptionLowRefillThreshold: 1,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedLooseAdded: 180,
|
||||||
|
expectedRemainingRefills: 1,
|
||||||
|
expectedTotalPills: 360,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tube",
|
||||||
|
payload: {
|
||||||
|
...tubeMedication,
|
||||||
|
prescriptionEnabled: true,
|
||||||
|
prescriptionAuthorizedRefills: 4,
|
||||||
|
prescriptionRemainingRefills: 3,
|
||||||
|
prescriptionLowRefillThreshold: 1,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||||
|
expectedPacksAdded: 2,
|
||||||
|
expectedLooseAdded: 80,
|
||||||
|
expectedRemainingRefills: 1,
|
||||||
|
expectedTotalPills: 160,
|
||||||
|
},
|
||||||
|
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||||
|
payload,
|
||||||
|
refillPayload,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedLooseAdded,
|
||||||
|
expectedRemainingRefills,
|
||||||
|
expectedTotalPills,
|
||||||
|
}) => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: refillPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
|
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||||
|
expect(refillData.prescription.used).toBe(true);
|
||||||
|
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
|
packsAdded: expectedPacksAdded,
|
||||||
|
loosePillsAdded: expectedLooseAdded,
|
||||||
|
usedPrescription: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should keep tube refill additive and preserve amount baseline", async () => {
|
it("should keep tube refill additive and preserve amount baseline", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ describe("medication enrichment", () => {
|
|||||||
generic_name: "Semaglutide",
|
generic_name: "Semaglutide",
|
||||||
dosage_form: "Tablet",
|
dosage_form: "Tablet",
|
||||||
marketing_start_date: "20240101",
|
marketing_start_date: "20240101",
|
||||||
|
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -203,9 +204,23 @@ describe("medication enrichment", () => {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
expect(response.results.find((result) => result.code === "00011-1111")?.packageOptions).toEqual([
|
||||||
|
{
|
||||||
|
label: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||||
|
description: "2 blisters in 1 carton / 10 tablets in 1 blister",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
totalPills: 20,
|
||||||
|
looseTablets: 0,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
|
it("prioritizes results with package sizes before source-only matches", async () => {
|
||||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
fetchMock.mockImplementation((url: string) => {
|
fetchMock.mockImplementation((url: string) => {
|
||||||
@@ -242,6 +257,7 @@ describe("medication enrichment", () => {
|
|||||||
generic_name: "Acetylsalicylic acid",
|
generic_name: "Acetylsalicylic acid",
|
||||||
dosage_form: "Tablet",
|
dosage_form: "Tablet",
|
||||||
marketing_start_date: "20240101",
|
marketing_start_date: "20240101",
|
||||||
|
packaging: [{ description: "2 blisters in 1 carton / 10 tablets in 1 blister" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -255,19 +271,72 @@ describe("medication enrichment", () => {
|
|||||||
expect(response.hasMore).toBe(false);
|
expect(response.hasMore).toBe(false);
|
||||||
expect(response.results).toHaveLength(3);
|
expect(response.results).toHaveLength(3);
|
||||||
expect(response.results[0]).toMatchObject({
|
expect(response.results[0]).toMatchObject({
|
||||||
code: "1191",
|
|
||||||
source: "rxnorm",
|
|
||||||
});
|
|
||||||
expect(response.results[1]).toMatchObject({
|
|
||||||
code: "00011-1111",
|
code: "00011-1111",
|
||||||
source: "openfda",
|
source: "openfda",
|
||||||
});
|
});
|
||||||
|
expect(response.results[1]).toMatchObject({
|
||||||
|
code: "1191",
|
||||||
|
source: "rxnorm",
|
||||||
|
});
|
||||||
expect(response.results[2]).toMatchObject({
|
expect(response.results[2]).toMatchObject({
|
||||||
code: "EMA-ASPIRIN",
|
code: "EMA-ASPIRIN",
|
||||||
source: "ema",
|
source: "ema",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts richer package hits ahead of package-bearing results with fewer options", async () => {
|
||||||
|
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||||
|
}
|
||||||
|
if (url.includes("/drugs.json?name=")) {
|
||||||
|
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "Ibuprofen Max",
|
||||||
|
generic_name: "Ibuprofen",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
marketing_start_date: "20240101",
|
||||||
|
packaging: [{ description: "60 tablets in 1 bottle" }, { description: "120 tablets in 1 bottle" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product_ndc: "00022-2222",
|
||||||
|
brand_name: "Ibuprofen Compact",
|
||||||
|
generic_name: "Ibuprofen",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
marketing_start_date: "20240101",
|
||||||
|
packaging: [{ description: "20 tablets in 1 blister" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await searchMedicationEnrichment("Ibuprofen", 3);
|
||||||
|
|
||||||
|
expect(response.results.slice(0, 2)).toMatchObject([
|
||||||
|
{
|
||||||
|
code: "00011-1111",
|
||||||
|
source: "openfda",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "00022-2222",
|
||||||
|
source: "openfda",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(response.results[0].packageOptions).toHaveLength(2);
|
||||||
|
expect(response.results[1].packageOptions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("validates malformed search requests", async () => {
|
it("validates malformed search requests", async () => {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
|
|
||||||
@@ -346,6 +415,89 @@ describe("medication enrichment", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes package suggestions from openFDA fallback in route responses", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
fetchMock.mockImplementation((url: string) => {
|
||||||
|
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse([
|
||||||
|
createEmaRow({
|
||||||
|
name_of_medicine: "Tylenol 500 mg tablets",
|
||||||
|
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||||
|
active_substance: "Acetaminophen",
|
||||||
|
ema_product_number: "EMA-TYLENOL",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||||
|
return Promise.resolve(jsonResponse({ idGroup: {} }));
|
||||||
|
}
|
||||||
|
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
product_ndc: "00011-1111",
|
||||||
|
brand_name: "Tylenol",
|
||||||
|
generic_name: "Acetaminophen",
|
||||||
|
dosage_form: "Tablet",
|
||||||
|
active_ingredients: [{ name: "Acetaminophen", strength: "500 mg" }],
|
||||||
|
packaging: [{ description: "30 tablets in 1 bottle" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medication-enrichment/enrich",
|
||||||
|
payload: {
|
||||||
|
query: "Paracetamol",
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
selection: {
|
||||||
|
name: "Tylenol 500 mg tablets",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
source: "ema+openfda",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
medicationForm: "tablet",
|
||||||
|
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||||
|
packageOptions: [
|
||||||
|
{
|
||||||
|
label: "30 tablets in 1 bottle",
|
||||||
|
description: "30 tablets in 1 bottle",
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills: 30,
|
||||||
|
looseTablets: 30,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
rxNormMatched: false,
|
||||||
|
openFdaMatched: true,
|
||||||
|
partial: false,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
||||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||||
|
|
||||||
@@ -459,6 +611,7 @@ describe("medication enrichment", () => {
|
|||||||
generic_name: "Ibuprofen",
|
generic_name: "Ibuprofen",
|
||||||
dosage_form: "Tablet",
|
dosage_form: "Tablet",
|
||||||
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||||
|
packaging: [{ description: "100 mL in 1 bottle" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -506,6 +659,20 @@ describe("medication enrichment", () => {
|
|||||||
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||||
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||||
],
|
],
|
||||||
|
packageOptions: [
|
||||||
|
{
|
||||||
|
label: "100 mL in 1 bottle",
|
||||||
|
description: "100 mL in 1 bottle",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 100,
|
||||||
|
packageAmountValue: 100,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched: true,
|
rxNormMatched: true,
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
|
|
||||||
* Tests adding refills to medication stock and retrieving refill history.
|
|
||||||
*/
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
buildTestApp,
|
|
||||||
clearTestData,
|
|
||||||
closeTestApp,
|
|
||||||
createTestMedication,
|
|
||||||
createTestUser,
|
|
||||||
type TestContext,
|
|
||||||
} from "./setup.js";
|
|
||||||
|
|
||||||
// Store userId at module level so routes can access it
|
|
||||||
let currentUserId = 1;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Route Registration
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
async function registerRefillRoutes(ctx: TestContext) {
|
|
||||||
const { app, client } = ctx;
|
|
||||||
|
|
||||||
// POST /medications/:id/refill - Add stock and record history
|
|
||||||
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
|
||||||
"/medications/:id/refill",
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = currentUserId;
|
|
||||||
const medId = parseInt(request.params.id, 10);
|
|
||||||
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (packsAdded < 0 || loosePillsAdded < 0) {
|
|
||||||
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
|
||||||
}
|
|
||||||
if (packsAdded === 0 && loosePillsAdded === 0) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check medication exists and belongs to user
|
|
||||||
const medResult = await client.execute({
|
|
||||||
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
|
||||||
FROM medications WHERE id = ? AND user_id = ?`,
|
|
||||||
args: [medId, userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (medResult.rows.length === 0) {
|
|
||||||
return reply.status(404).send({ error: "Medication not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const med = medResult.rows[0];
|
|
||||||
const newPackCount = (med.pack_count as number) + packsAdded;
|
|
||||||
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
|
||||||
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
|
||||||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
|
||||||
|
|
||||||
// Update medication stock
|
|
||||||
await client.execute({
|
|
||||||
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
|
||||||
args: [newPackCount, newLooseTablets, medId],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Record refill history
|
|
||||||
await client.execute({
|
|
||||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
args: [medId, userId, packsAdded, loosePillsAdded],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
pillsAdded: totalPillsAdded,
|
|
||||||
newPackCount,
|
|
||||||
newLooseTablets,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /medications/:id/refills - Get refill history
|
|
||||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
|
||||||
const userId = currentUserId;
|
|
||||||
const medId = parseInt(request.params.id, 10);
|
|
||||||
|
|
||||||
// Check medication exists and belongs to user
|
|
||||||
const medResult = await client.execute({
|
|
||||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
|
||||||
args: [medId, userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (medResult.rows.length === 0) {
|
|
||||||
return reply.status(404).send({ error: "Medication not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get refill history, newest first
|
|
||||||
const refillResult = await client.execute({
|
|
||||||
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
|
||||||
FROM refill_history
|
|
||||||
WHERE medication_id = ? AND user_id = ?
|
|
||||||
ORDER BY refill_date DESC`,
|
|
||||||
args: [medId, userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
refills: refillResult.rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
packsAdded: r.packs_added,
|
|
||||||
loosePillsAdded: r.loose_pills_added,
|
|
||||||
refillDate: r.refill_date,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Tests
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
describe("Refill API", () => {
|
|
||||||
let ctx: TestContext;
|
|
||||||
let userId: number;
|
|
||||||
let medId: number;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
ctx = await buildTestApp();
|
|
||||||
await registerRefillRoutes(ctx);
|
|
||||||
await ctx.app.ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await closeTestApp(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await clearTestData(ctx.client);
|
|
||||||
// Create test user
|
|
||||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
|
||||||
// Update the module-level userId so routes use the correct one
|
|
||||||
currentUserId = userId;
|
|
||||||
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
|
||||||
medId = await createTestMedication(ctx.client, {
|
|
||||||
userId,
|
|
||||||
name: "Test Med",
|
|
||||||
packCount: 1,
|
|
||||||
blistersPerPack: 10,
|
|
||||||
pillsPerBlister: 10,
|
|
||||||
looseTablets: 5,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /medications/:id/refill
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("POST /medications/:id/refill", () => {
|
|
||||||
it("should add packs to medication stock", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 2 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const data = response.json();
|
|
||||||
expect(data.success).toBe(true);
|
|
||||||
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
|
||||||
expect(data.newPackCount).toBe(3); // 1 + 2
|
|
||||||
|
|
||||||
// Verify in database
|
|
||||||
const result = await ctx.client.execute({
|
|
||||||
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].pack_count).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add loose pills to medication stock", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { loosePillsAdded: 15 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const data = response.json();
|
|
||||||
expect(data.success).toBe(true);
|
|
||||||
expect(data.pillsAdded).toBe(15);
|
|
||||||
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
|
||||||
|
|
||||||
// Verify in database
|
|
||||||
const result = await ctx.client.execute({
|
|
||||||
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].loose_tablets).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add both packs and loose pills", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const data = response.json();
|
|
||||||
expect(data.success).toBe(true);
|
|
||||||
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
|
||||||
expect(data.newPackCount).toBe(2);
|
|
||||||
expect(data.newLooseTablets).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should record refill in history", async () => {
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check history
|
|
||||||
const result = await ctx.client.execute({
|
|
||||||
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
expect(result.rows.length).toBe(1);
|
|
||||||
expect(result.rows[0].packs_added).toBe(2);
|
|
||||||
expect(result.rows[0].loose_pills_added).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject refill with zero amounts", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
expect(response.json().error).toContain("At least one");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject refill with negative amounts", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: -1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
|
||||||
expect(response.json().error).toContain("non-negative");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return 404 for non-existent medication", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/99999/refill`,
|
|
||||||
payload: { packsAdded: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
expect(response.json().error).toBe("Medication not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /medications/:id/refills
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("GET /medications/:id/refills", () => {
|
|
||||||
it("should return empty array when no refills", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/medications/${medId}/refills`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json()).toEqual({ refills: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return refill history newest first", async () => {
|
|
||||||
// Add two refills with different values so we can identify them
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
|
||||||
await new Promise((r) => setTimeout(r, 1100));
|
|
||||||
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/medications/${medId}/refills`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
const data = response.json();
|
|
||||||
expect(data.refills).toHaveLength(2);
|
|
||||||
|
|
||||||
// Newest first (loose pills - added second)
|
|
||||||
expect(data.refills[0].packsAdded).toBe(0);
|
|
||||||
expect(data.refills[0].loosePillsAdded).toBe(20);
|
|
||||||
|
|
||||||
// Older (packs - added first)
|
|
||||||
expect(data.refills[1].packsAdded).toBe(1);
|
|
||||||
expect(data.refills[1].loosePillsAdded).toBe(0);
|
|
||||||
|
|
||||||
// Each entry should have an id and refillDate
|
|
||||||
for (const refill of data.refills) {
|
|
||||||
expect(refill.id).toBeTypeOf("number");
|
|
||||||
expect(refill.refillDate).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return 404 for non-existent medication", async () => {
|
|
||||||
const response = await ctx.app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/medications/99999/refills`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(404);
|
|
||||||
expect(response.json().error).toBe("Medication not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cascade Delete Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("Cascade Delete", () => {
|
|
||||||
it("should delete refill history when medication is deleted", async () => {
|
|
||||||
// Add a refill
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify refill exists
|
|
||||||
let result = await ctx.client.execute({
|
|
||||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].count).toBe(1);
|
|
||||||
|
|
||||||
// Delete medication
|
|
||||||
await ctx.client.execute({
|
|
||||||
sql: `DELETE FROM medications WHERE id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify refill history was cascade deleted
|
|
||||||
result = await ctx.client.execute({
|
|
||||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
|
||||||
args: [medId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should delete refill history when user is deleted", async () => {
|
|
||||||
// Add a refill
|
|
||||||
await ctx.app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify refill exists
|
|
||||||
let result = await ctx.client.execute({
|
|
||||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].count).toBe(1);
|
|
||||||
|
|
||||||
// Delete user
|
|
||||||
await ctx.client.execute({
|
|
||||||
sql: `DELETE FROM users WHERE id = ?`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify refill history was cascade deleted
|
|
||||||
result = await ctx.client.execute({
|
|
||||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
expect(result.rows[0].count).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Agent Memory Notes
|
||||||
|
|
||||||
|
Purpose: persistent agent work memory to survive context loss.
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
|
||||||
|
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
|
||||||
|
- Root cause: The GitHub "Frontend Build" check actually failed in the frontend lint step because `frontend/src/test/pages/MedicationsPage.test.tsx` contained a whitespace-only line that Biome rejects.
|
||||||
|
- Fix: Removed the stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` and revalidated frontend lint/build locally.
|
||||||
|
|
||||||
|
- Task: Split the medication enrichment lookup improvements into a standalone feature branch and repair the shared frontend tests until the focused validation set passed.
|
||||||
|
- Decisions: Kept this branch limited to enrichment lookup/search/apply behavior, restored corrupted MedicationsPage and MobileEditModal test structure from clean main patterns, and retained desktop/mobile parity inside the feature scope.
|
||||||
|
- Files touched: README.md, backend/src/routes/medication-enrichment.ts, backend/src/services/medication-enrichment.ts, backend/src/test/medication-enrichment.test.ts, frontend/src/components/MedicationEnrichmentSection.tsx, frontend/src/components/MobileEditModal.tsx, frontend/src/i18n/de.json, frontend/src/i18n/en.json, frontend/src/pages/MedicationsPage.tsx, frontend/src/styles.css, frontend/src/test/components/MedicationEnrichmentSection.test.tsx, frontend/src/test/components/MobileEditModal.test.tsx, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/types/index.ts, frontend/src/utils/index.ts, frontend/src/utils/medication-enrichment.ts.
|
||||||
|
- Follow-up: Merge the refreshed feature branch once GitHub CI is green again.
|
||||||
|
|
||||||
|
- Task: Merge the refreshed feature branch on top of the already shipped stock/refill semantics changes without losing shared test coverage or work-log history.
|
||||||
|
- Decisions: Kept the stock/refill doku history entries while resolving add/add conflicts and combined both branches' MedicationsPage tests in the shared file.
|
||||||
|
- Files touched: doku/memory_notes.md, doku/report.md, frontend/src/test/pages/MedicationsPage.test.tsx.
|
||||||
|
- Follow-up: Re-run the minimum frontend validation and push the conflict-resolution commit for PR #475.
|
||||||
|
|
||||||
|
- Task: Review and merge the open Dependabot pull requests after verifying scope and CI state.
|
||||||
|
- Decisions: Merged only dependency-only PRs with acceptable checks; accepted skipped jobs on the root-only tooling bump because the diff did not touch frontend or backend runtime code.
|
||||||
|
- Merged PRs: #468 (`@biomejs/biome` root bump), #469 (frontend dependency group bump), #470 (backend dependency group bump).
|
||||||
|
- Follow-up: Synced local `main` to commit `39c19ab` and confirmed there are no remaining open Dependabot PRs from this reviewed set.
|
||||||
|
|
||||||
|
- Task: Investigate why last week's weekly triage report issue stayed open after a newer report was created.
|
||||||
|
- Root cause: `.github/workflows/weekly-triage-report.yml` always created a new issue and had no cleanup step for older open weekly report issues; `.github/agents/release-manager.agent.md` also lacked an explicit weekly-report closure rule.
|
||||||
|
- Fix: Added workflow logic to close older open weekly triage reports before publishing the new one and added a dedicated "Weekly Triage Report Hygiene" rule to the release-manager agent instructions.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Work Report
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
|
||||||
|
- What changed:
|
||||||
|
- Confirmed the GitHub "Frontend Build" job was failing in the frontend lint step, not in the Vite production build.
|
||||||
|
- Removed a stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` that caused Biome formatting failure.
|
||||||
|
- Validation:
|
||||||
|
- `cd frontend && npm run lint`: passed after the whitespace fix.
|
||||||
|
- `cd frontend && npm run build`: passed locally; production bundle build remains green.
|
||||||
|
- Result: The branch was ready to push for CI re-run from a testing/build perspective.
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
- Scope: Isolate and validate the medication enrichment lookup work as its own PR-ready feature branch.
|
||||||
|
- What changed:
|
||||||
|
- Kept the branch focused on medication enrichment backend lookup logic, the shared lookup section, desktop/mobile editor parity, lookup utilities, translations, and the matching documentation update.
|
||||||
|
- Repaired split-induced corruption in the shared MedicationsPage and MobileEditModal frontend tests so the feature branch is parse-clean and locally testable again.
|
||||||
|
- Preserved the dedicated medication enrichment backend test file and added the shared frontend utility file used by the grouped lookup flow.
|
||||||
|
- Validation:
|
||||||
|
- Backend changed-file Biome: passed.
|
||||||
|
- Frontend changed-file Biome: passed.
|
||||||
|
- Backend Vitest `backend/src/test/medication-enrichment.test.ts`: passed (`12` tests, `0` failures).
|
||||||
|
- Frontend Vitest targeted medication enrichment files: passed (`116` tests, `0` failures).
|
||||||
|
- Result: This branch was locally green and ready for upstream PR creation.
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
- Scope: Reconcile PR #475 with the already merged stock/refill branch so the feature PR can merge cleanly on top of the new main.
|
||||||
|
- What changed:
|
||||||
|
- Kept the required doku history from both PR tracks while resolving the add/add conflicts in `doku/memory_notes.md` and `doku/report.md`.
|
||||||
|
- Combined the shared `frontend/src/test/pages/MedicationsPage.test.tsx` tail section so the medication enrichment tests and the already shipped stock-capacity list tests both remain present.
|
||||||
|
- Validation:
|
||||||
|
- Minimum frontend validation is rerun after conflict resolution before pushing the refreshed branch.
|
||||||
|
- Result: The feature branch is conflict-free locally and ready for the final revalidation/push cycle.
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
- Scope: Review and merge the currently open Dependabot PRs.
|
||||||
|
- What changed:
|
||||||
|
- Reviewed the three open Dependabot PRs and verified each diff was limited to package manifest and lockfile updates.
|
||||||
|
- Confirmed the frontend and backend dependency-group PRs had green relevant checks before merge.
|
||||||
|
- Accepted the skipped frontend/backend/E2E jobs on the root-level Biome bump because the change was tooling-only at repository root scope.
|
||||||
|
- Squash-merged PRs `#468`, `#469`, and `#470`.
|
||||||
|
- Validation:
|
||||||
|
- Synced local `main` with `github/main` after the merges.
|
||||||
|
- Confirmed there are no remaining open Dependabot PRs in this reviewed batch.
|
||||||
|
- Result: All currently reviewed Dependabot updates are merged and local `main` matches the remote shipping branch again.
|
||||||
|
|
||||||
|
### 2026-03-25
|
||||||
|
- Scope: Prevent duplicate open weekly triage report issues.
|
||||||
|
- What changed:
|
||||||
|
- Confirmed the weekly triage workflow was creating a new report issue every Monday without closing older open weekly report issues first.
|
||||||
|
- Updated `.github/workflows/weekly-triage-report.yml` so older open `Weekly Triage Report - ...` issues are commented on and closed before the next report issue is created.
|
||||||
|
- Added an explicit weekly-report closure rule to `.github/agents/release-manager.agent.md`.
|
||||||
|
- Validation:
|
||||||
|
- Reviewed the current open weekly triage reports and confirmed both `#451` and `#471` were open before the workflow fix.
|
||||||
|
- Performed a local YAML parse check for the updated workflow.
|
||||||
|
- Result: Future weekly triage runs will keep only one open weekly report issue, and the release-manager guidance now states that requirement explicitly.
|
||||||
Generated
+148
-159
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.10.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.6",
|
"react-i18next": "^16.6.1",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.6",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@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",
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.1",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.1",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -136,9 +136,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -169,9 +169,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
|
||||||
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
|
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -185,20 +185,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.4.7",
|
"@biomejs/cli-darwin-arm64": "2.4.8",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.7",
|
"@biomejs/cli-darwin-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.7",
|
"@biomejs/cli-linux-arm64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.7",
|
"@biomejs/cli-linux-arm64-musl": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64": "2.4.7",
|
"@biomejs/cli-linux-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.7",
|
"@biomejs/cli-linux-x64-musl": "2.4.8",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.7",
|
"@biomejs/cli-win32-arm64": "2.4.8",
|
||||||
"@biomejs/cli-win32-x64": "2.4.7"
|
"@biomejs/cli-win32-x64": "2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
|
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -213,9 +213,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
|
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -230,9 +230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
|
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -247,9 +247,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
|
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -264,9 +264,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
|
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -281,9 +281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
|
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -298,9 +298,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
|
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -315,9 +315,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
|
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -485,9 +485,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -497,9 +497,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -581,20 +581,10 @@
|
|||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/runtime": {
|
|
||||||
"version": "0.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
|
||||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.115.0",
|
"version": "0.120.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -618,9 +608,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -635,9 +625,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -652,9 +642,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -669,9 +659,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -686,9 +676,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -703,9 +693,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -720,9 +710,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -737,9 +727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -754,9 +744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -771,9 +761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -788,9 +778,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -805,9 +795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -822,9 +812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -839,9 +829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -856,9 +846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1544,26 +1534,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.8.18",
|
"version": "25.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.4.tgz",
|
||||||
"integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==",
|
"integrity": "sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
"url": "https://locize.com"
|
"url": "https://www.locize.com/i18next"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://locize.com/i18next.html"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6"
|
"@babel/runtime": "^7.29.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -1648,14 +1638,14 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.0.0",
|
"version": "29.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
|
||||||
"integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==",
|
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^5.0.1",
|
"@asamuzakjp/css-color": "^5.0.1",
|
||||||
"@asamuzakjp/dom-selector": "^7.0.2",
|
"@asamuzakjp/dom-selector": "^7.0.3",
|
||||||
"@bramus/specificity": "^2.4.2",
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||||
"@exodus/bytes": "^1.15.0",
|
"@exodus/bytes": "^1.15.0",
|
||||||
@@ -1669,7 +1659,7 @@
|
|||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^6.0.1",
|
"tough-cookie": "^6.0.1",
|
||||||
"undici": "^7.24.3",
|
"undici": "^7.24.5",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
@@ -2213,12 +2203,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "16.5.8",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.1.tgz",
|
||||||
"integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==",
|
"integrity": "sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.29.2",
|
||||||
"html-parse-stringify": "^3.0.1",
|
"html-parse-stringify": "^3.0.1",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -2310,14 +2300,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.115.0",
|
"@oxc-project/types": "=0.120.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2326,27 +2316,27 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.9",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2565,9 +2555,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.24.3",
|
"version": "7.24.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
|
||||||
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2591,17 +2581,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/runtime": "0.115.0",
|
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"rolldown": "1.0.0-rc.9",
|
"rolldown": "1.0.0-rc.10",
|
||||||
"tinyglobby": "^0.2.15"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2618,7 +2607,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
"@vitejs/devtools": "^0.1.0",
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -27,17 +27,17 @@
|
|||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.10.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.6",
|
"react-i18next": "^16.6.1",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.6",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@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",
|
||||||
@@ -48,9 +48,9 @@
|
|||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.1",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.1",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getMedDisplayName,
|
getMedDisplayName,
|
||||||
getMedTotal,
|
getMedTotal,
|
||||||
getPackageSize,
|
getPackageSize,
|
||||||
|
getStockDisplayCapacity,
|
||||||
type IntakeUnit,
|
type IntakeUnit,
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
@@ -213,9 +214,10 @@ 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);
|
||||||
|
const stockDisplayCapacity = getStockDisplayCapacity(selectedMed);
|
||||||
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
||||||
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
|
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
? stockDisplayCapacity
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
: 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;
|
||||||
@@ -226,7 +228,7 @@ export function MedDetailModal({
|
|||||||
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 = isAmountBasedPackageType(selectedMed.packageType)
|
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
? stockDisplayCapacity
|
||||||
: Math.max(0, structuralMax);
|
: 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 = (() => {
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
MedicationEnrichmentEnrichResponse,
|
MedicationEnrichmentEnrichResponse,
|
||||||
|
MedicationEnrichmentPackageOption,
|
||||||
MedicationEnrichmentSearchResult,
|
MedicationEnrichmentSearchResult,
|
||||||
MedicationEnrichmentStrengthOption,
|
MedicationEnrichmentStrengthOption,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
|
import { getMedicationEnrichmentDisplayResultKey } from "../utils/medication-enrichment";
|
||||||
|
|
||||||
|
const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi;
|
||||||
|
const PACKAGE_CONTENT_UNIT_PATTERNS = [
|
||||||
|
{ pattern: /\bcapsules?\b/i, key: "capsule" },
|
||||||
|
{ pattern: /\btablets?\b/i, key: "tablet" },
|
||||||
|
{ pattern: /\bcaplets?\b/i, key: "caplet" },
|
||||||
|
{ pattern: /\bpills?\b/i, key: "pill" },
|
||||||
|
] as const;
|
||||||
|
const INITIAL_VISIBLE_STRENGTH_OPTIONS = 12;
|
||||||
|
|
||||||
|
type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
export interface MedicationEnrichmentViewModel {
|
export interface MedicationEnrichmentViewModel {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -15,12 +28,15 @@ export interface MedicationEnrichmentViewModel {
|
|||||||
hasSearched: boolean;
|
hasSearched: boolean;
|
||||||
searchError: string | null;
|
searchError: string | null;
|
||||||
applyingCode: string | null;
|
applyingCode: string | null;
|
||||||
|
applyingPackageLabel: string | null;
|
||||||
activeResultCode: string | null;
|
activeResultCode: string | null;
|
||||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||||
enrichError: string | null;
|
enrichError: string | null;
|
||||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
appliedStrengthLabel: string | null;
|
appliedStrengthLabel: string | null;
|
||||||
|
appliedPackageLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MedicationEnrichmentSectionProps {
|
export interface MedicationEnrichmentSectionProps {
|
||||||
@@ -28,8 +44,194 @@ export interface MedicationEnrichmentSectionProps {
|
|||||||
onQueryChange: (value: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
onLoadMoreResults?: () => void;
|
onLoadMoreResults?: () => void;
|
||||||
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
|
onApplyResult: (
|
||||||
|
result: MedicationEnrichmentSearchResult,
|
||||||
|
preferredPackageOption?: MedicationEnrichmentPackageOption
|
||||||
|
) => void;
|
||||||
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
||||||
|
onApplyPackage: (option: MedicationEnrichmentPackageOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MedicationEnrichmentPackageChoice = {
|
||||||
|
option: MedicationEnrichmentPackageOption;
|
||||||
|
sourceResult: MedicationEnrichmentSearchResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MedicationEnrichmentDisplayResult = {
|
||||||
|
displayKey: string;
|
||||||
|
representative: MedicationEnrichmentSearchResult;
|
||||||
|
sourceResults: MedicationEnrichmentSearchResult[];
|
||||||
|
packageChoices: MedicationEnrichmentPackageChoice[];
|
||||||
|
firstIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePackageOptionDisplayText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ")
|
||||||
|
.replace(/\b([A-Z]{2,})\b/g, (match) => match.toLowerCase())
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageContainerTranslationKey(packageType: MedicationEnrichmentPackageOption["packageType"]): string {
|
||||||
|
switch (packageType) {
|
||||||
|
case "blister":
|
||||||
|
return "form.enrichment.packageContainers.blister";
|
||||||
|
case "bottle":
|
||||||
|
return "form.enrichment.packageContainers.bottle";
|
||||||
|
case "liquid_container":
|
||||||
|
return "form.enrichment.packageContainers.liquidContainer";
|
||||||
|
case "tube":
|
||||||
|
return "form.enrichment.packageContainers.tube";
|
||||||
|
default:
|
||||||
|
return "form.enrichment.packageContainers.bottle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPackageContentUnitKey(value: string): string {
|
||||||
|
for (const candidate of PACKAGE_CONTENT_UNIT_PATTERNS) {
|
||||||
|
if (candidate.pattern.test(value)) {
|
||||||
|
return candidate.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSolidPackageCount(count: number, sourceText: string, t: TranslateFunction): string {
|
||||||
|
const unitKey = detectPackageContentUnitKey(sourceText);
|
||||||
|
return `${count} ${t(`form.enrichment.packageUnits.${unitKey}`, { count })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPackageContainerCount(option: MedicationEnrichmentPackageOption, t: TranslateFunction): string {
|
||||||
|
return t(getPackageContainerTranslationKey(option.packageType), { count: Math.max(option.packCount, 1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPackageOptionKey(option: MedicationEnrichmentPackageOption): string {
|
||||||
|
const sourceText = normalizePackageOptionDisplayText(option.description || option.label);
|
||||||
|
const detectedUnit =
|
||||||
|
option.packageType === "bottle" || option.packageType === "blister"
|
||||||
|
? detectPackageContentUnitKey(sourceText)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return JSON.stringify([
|
||||||
|
option.packageType,
|
||||||
|
option.packCount,
|
||||||
|
option.blistersPerPack,
|
||||||
|
option.pillsPerBlister,
|
||||||
|
option.totalPills,
|
||||||
|
option.looseTablets,
|
||||||
|
option.packageAmountValue,
|
||||||
|
option.packageAmountUnit,
|
||||||
|
detectedUnit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] {
|
||||||
|
const uniqueOptions = new Map<string, MedicationEnrichmentPackageOption>();
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
const key = buildPackageOptionKey(option);
|
||||||
|
if (!uniqueOptions.has(key)) {
|
||||||
|
uniqueOptions.set(key, option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...uniqueOptions.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPackageOptionDisplayText(
|
||||||
|
value: MedicationEnrichmentPackageOption | string,
|
||||||
|
t: TranslateFunction
|
||||||
|
): string {
|
||||||
|
const rawText = typeof value === "string" ? value : value.description || value.label;
|
||||||
|
const cleanedText = normalizePackageOptionDisplayText(rawText);
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return cleanedText || rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageContainerLabel = formatPackageContainerCount(value, t);
|
||||||
|
|
||||||
|
if (value.packageType === "blister") {
|
||||||
|
if (value.blistersPerPack !== null && value.blistersPerPack > 1 && value.pillsPerBlister !== null) {
|
||||||
|
return `${packageContainerLabel} · ${value.blistersPerPack} × ${formatSolidPackageCount(
|
||||||
|
value.pillsPerBlister,
|
||||||
|
cleanedText,
|
||||||
|
t
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blisterCount = value.pillsPerBlister ?? value.totalPills;
|
||||||
|
if (blisterCount !== null && blisterCount > 0) {
|
||||||
|
return `${packageContainerLabel} · ${formatSolidPackageCount(blisterCount, cleanedText, t)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.packageType === "bottle") {
|
||||||
|
const totalCount = value.totalPills ?? value.looseTablets;
|
||||||
|
if (totalCount !== null && totalCount > 0) {
|
||||||
|
const countPerContainer =
|
||||||
|
value.packCount > 1 && totalCount % value.packCount === 0 ? totalCount / value.packCount : totalCount;
|
||||||
|
return `${packageContainerLabel} · ${formatSolidPackageCount(countPerContainer, cleanedText, t)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.packageType === "liquid_container" || value.packageType === "tube") &&
|
||||||
|
value.packageAmountValue !== null &&
|
||||||
|
value.packageAmountUnit
|
||||||
|
) {
|
||||||
|
return `${packageContainerLabel} · ${value.packageAmountValue} ${value.packageAmountUnit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedText || rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMedicationDisplayResults(
|
||||||
|
results: MedicationEnrichmentSearchResult[]
|
||||||
|
): MedicationEnrichmentDisplayResult[] {
|
||||||
|
const grouped = new Map<
|
||||||
|
string,
|
||||||
|
MedicationEnrichmentDisplayResult & { packageChoicesByKey: Map<string, MedicationEnrichmentPackageChoice> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const displayKey = getMedicationEnrichmentDisplayResultKey(result);
|
||||||
|
const existing = grouped.get(displayKey);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const packageChoicesByKey = new Map<string, MedicationEnrichmentPackageChoice>();
|
||||||
|
for (const option of result.packageOptions) {
|
||||||
|
packageChoicesByKey.set(buildPackageOptionKey(option), { option, sourceResult: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.set(displayKey, {
|
||||||
|
displayKey,
|
||||||
|
representative: result,
|
||||||
|
sourceResults: [result],
|
||||||
|
packageChoices: [...packageChoicesByKey.values()],
|
||||||
|
packageChoicesByKey,
|
||||||
|
firstIndex: index,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.sourceResults.push(result);
|
||||||
|
for (const option of result.packageOptions) {
|
||||||
|
const key = buildPackageOptionKey(option);
|
||||||
|
if (!existing.packageChoicesByKey.has(key)) {
|
||||||
|
existing.packageChoicesByKey.set(key, { option, sourceResult: result });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing.packageChoices = [...existing.packageChoicesByKey.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...grouped.values()]
|
||||||
|
.sort(
|
||||||
|
(left, right) => right.packageChoices.length - left.packageChoices.length || left.firstIndex - right.firstIndex
|
||||||
|
)
|
||||||
|
.map(({ packageChoicesByKey: _packageChoicesByKey, ...result }) => result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MedicationEnrichmentSection({
|
export function MedicationEnrichmentSection({
|
||||||
@@ -39,6 +241,7 @@ export function MedicationEnrichmentSection({
|
|||||||
onLoadMoreResults,
|
onLoadMoreResults,
|
||||||
onApplyResult,
|
onApplyResult,
|
||||||
onApplyStrength,
|
onApplyStrength,
|
||||||
|
onApplyPackage,
|
||||||
}: MedicationEnrichmentSectionProps) {
|
}: MedicationEnrichmentSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
||||||
@@ -49,13 +252,29 @@ export function MedicationEnrichmentSection({
|
|||||||
state.enrichError !== null ||
|
state.enrichError !== null ||
|
||||||
state.results.length > 0 ||
|
state.results.length > 0 ||
|
||||||
state.appliedSelection !== null ||
|
state.appliedSelection !== null ||
|
||||||
|
state.packageOptions.length > 0 ||
|
||||||
state.strengthOptions.length > 0 ||
|
state.strengthOptions.length > 0 ||
|
||||||
|
state.appliedPackageLabel !== null ||
|
||||||
state.appliedStrengthLabel !== null ||
|
state.appliedStrengthLabel !== null ||
|
||||||
Boolean(state.meta?.partial);
|
Boolean(state.meta?.partial);
|
||||||
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
|
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
|
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
|
||||||
|
const [visibleStrengthOptionCount, setVisibleStrengthOptionCount] = useState(INITIAL_VISIBLE_STRENGTH_OPTIONS);
|
||||||
const autoExpandStateRef = useRef(shouldAutoExpand);
|
const autoExpandStateRef = useRef(shouldAutoExpand);
|
||||||
|
const resultRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
const displayResults = useMemo(() => buildMedicationDisplayResults(state.results), [state.results]);
|
||||||
|
const uniqueStatePackageOptions = useMemo(() => dedupePackageOptions(state.packageOptions), [state.packageOptions]);
|
||||||
|
const visibleStrengthOptions = state.strengthOptions.slice(0, visibleStrengthOptionCount);
|
||||||
|
const hasMoreStrengthOptions = state.strengthOptions.length > visibleStrengthOptions.length;
|
||||||
|
const appliedPackageOption = useMemo(
|
||||||
|
() => state.packageOptions.find((option) => option.label === state.appliedPackageLabel) ?? null,
|
||||||
|
[state.appliedPackageLabel, state.packageOptions]
|
||||||
|
);
|
||||||
|
const isLoadingInitialSearch = state.isSearching && displayResults.length === 0;
|
||||||
|
const isLoadingMoreResults = state.isSearching && displayResults.length > 0;
|
||||||
|
const showLoadMoreAction =
|
||||||
|
displayResults.length > 0 && (state.hasMoreResults || isLoadingMoreResults) && onLoadMoreResults;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
||||||
@@ -65,6 +284,26 @@ export function MedicationEnrichmentSection({
|
|||||||
autoExpandStateRef.current = shouldAutoExpand;
|
autoExpandStateRef.current = shouldAutoExpand;
|
||||||
}, [shouldAutoExpand]);
|
}, [shouldAutoExpand]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleStrengthOptionCount(INITIAL_VISIBLE_STRENGTH_OPTIONS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandedResultCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(animationFrameId);
|
||||||
|
}, [expandedResultCode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="full medication-enrichment-section">
|
<div className="full medication-enrichment-section">
|
||||||
<div className="medication-enrichment-header">
|
<div className="medication-enrichment-header">
|
||||||
@@ -74,7 +313,7 @@ export function MedicationEnrichmentSection({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary small"
|
className={`medication-enrichment-toggle-button ${isExpanded ? "secondary small" : "primary small"}`}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
onClick={() => setIsExpanded((current) => !current)}
|
onClick={() => setIsExpanded((current) => !current)}
|
||||||
>
|
>
|
||||||
@@ -119,8 +358,16 @@ export function MedicationEnrichmentSection({
|
|||||||
}}
|
}}
|
||||||
placeholder={t("form.enrichment.searchPlaceholder")}
|
placeholder={t("form.enrichment.searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
|
<button
|
||||||
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
|
type="button"
|
||||||
|
className={`secondary small medication-enrichment-action-button${isLoadingInitialSearch ? " is-loading" : ""}`}
|
||||||
|
onClick={onSearch}
|
||||||
|
disabled={!canSearch}
|
||||||
|
>
|
||||||
|
{isLoadingInitialSearch ? <span className="medication-enrichment-spinner" aria-hidden="true" /> : null}
|
||||||
|
<span>
|
||||||
|
{isLoadingInitialSearch ? t("form.enrichment.loadingSearch") : t("form.enrichment.searchAction")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -128,41 +375,79 @@ export function MedicationEnrichmentSection({
|
|||||||
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
|
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
|
||||||
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
|
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
|
||||||
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
|
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
|
||||||
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
|
{state.hasSearched && !state.isSearching && state.results.length === 0 && !state.searchError ? (
|
||||||
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.results.length > 0 ? (
|
{displayResults.length > 0 ? (
|
||||||
<div className="medication-enrichment-results">
|
<div className="medication-enrichment-results">
|
||||||
{state.results.map((result) => {
|
{displayResults.map((displayResult) => {
|
||||||
const isActive = state.activeResultCode === result.code;
|
const { representative, sourceResults, packageChoices, displayKey } = displayResult;
|
||||||
|
const isActive = sourceResults.some((result) => result.code === state.activeResultCode);
|
||||||
|
const authorisationHolder =
|
||||||
|
sourceResults.find((result) => result.authorisationHolder)?.authorisationHolder ?? null;
|
||||||
|
const therapeuticArea = sourceResults.find((result) => result.therapeuticArea)?.therapeuticArea ?? null;
|
||||||
|
const authorisationDate =
|
||||||
|
sourceResults.find((result) => result.authorisationDate)?.authorisationDate ?? null;
|
||||||
|
const hasPackageOptions = packageChoices.length > 0;
|
||||||
|
const hasActiveStrengthOptions = isActive && state.strengthOptions.length > 0;
|
||||||
|
const isApplyingPackageSelection =
|
||||||
|
isActive && state.applyingCode !== null && state.applyingPackageLabel !== null;
|
||||||
const hasDetails = Boolean(
|
const hasDetails = Boolean(
|
||||||
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
|
authorisationHolder ||
|
||||||
|
therapeuticArea ||
|
||||||
|
authorisationDate ||
|
||||||
|
hasPackageOptions ||
|
||||||
|
hasActiveStrengthOptions ||
|
||||||
|
isApplyingPackageSelection
|
||||||
);
|
);
|
||||||
const isDetailsExpanded = expandedResultCode === result.code;
|
const isDetailsExpanded = expandedResultCode === displayKey;
|
||||||
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
|
const activePackageOptions =
|
||||||
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
|
isActive && uniqueStatePackageOptions.length > 0
|
||||||
|
? uniqueStatePackageOptions
|
||||||
|
: packageChoices.map((choice) => choice.option);
|
||||||
|
const showInlinePackageChoices = activePackageOptions.length > 1;
|
||||||
|
const genericStatusClass = representative.genericStatus === "generic" ? "success" : "neutral";
|
||||||
|
const sourceClass = representative.source === "openfda" ? "warning" : "neutral";
|
||||||
let applyLabel = t("form.enrichment.applyAction");
|
let applyLabel = t("form.enrichment.applyAction");
|
||||||
if (state.applyingCode === result.code) {
|
if (isActive && state.applyingCode !== null) {
|
||||||
applyLabel = t("form.enrichment.applying");
|
applyLabel = t("form.enrichment.applying");
|
||||||
} else if (isActive && state.appliedSelection) {
|
} else if (isActive && state.appliedSelection) {
|
||||||
applyLabel = t("form.enrichment.applied");
|
applyLabel = t("form.enrichment.applied");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
|
<article
|
||||||
|
key={displayKey}
|
||||||
|
className={`medication-enrichment-result${isActive ? " active" : ""}`}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) {
|
||||||
|
resultRefs.current.set(displayKey, element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultRefs.current.delete(displayKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="medication-enrichment-result-header">
|
<div className="medication-enrichment-result-header">
|
||||||
<div className="medication-enrichment-result-names">
|
<div className="medication-enrichment-result-names">
|
||||||
<strong>{result.name}</strong>
|
<strong>{representative.name}</strong>
|
||||||
{result.genericName ? (
|
{representative.genericName ? (
|
||||||
<span className="medication-enrichment-result-generic">{result.genericName}</span>
|
<span className="medication-enrichment-result-generic">{representative.genericName}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="medication-enrichment-result-actions">
|
<div className="medication-enrichment-result-actions">
|
||||||
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
|
<span className={`pill ${hasPackageOptions ? "success" : "neutral"}`}>
|
||||||
{result.source === "ema" ? (
|
{hasPackageOptions
|
||||||
|
? t("form.enrichment.packageAvailable")
|
||||||
|
: t("form.enrichment.packageUnavailable")}
|
||||||
|
</span>
|
||||||
|
<span className={`pill ${sourceClass}`}>
|
||||||
|
{t(`form.enrichment.sources.${representative.source}`)}
|
||||||
|
</span>
|
||||||
|
{representative.source === "ema" ? (
|
||||||
<span className={`pill ${genericStatusClass}`}>
|
<span className={`pill ${genericStatusClass}`}>
|
||||||
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
|
{t(`form.enrichment.genericStatus.${representative.genericStatus}`)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{hasDetails ? (
|
{hasDetails ? (
|
||||||
@@ -171,7 +456,7 @@ export function MedicationEnrichmentSection({
|
|||||||
className="ghost small"
|
className="ghost small"
|
||||||
aria-expanded={isDetailsExpanded}
|
aria-expanded={isDetailsExpanded}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedResultCode((current) => (current === result.code ? null : result.code))
|
setExpandedResultCode((current) => (current === displayKey ? null : displayKey))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isDetailsExpanded
|
{isDetailsExpanded
|
||||||
@@ -179,38 +464,162 @@ export function MedicationEnrichmentSection({
|
|||||||
: t("form.enrichment.details.showAction")}
|
: t("form.enrichment.details.showAction")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
{showInlinePackageChoices ? null : (
|
||||||
type="button"
|
<button
|
||||||
className={isActive ? "secondary small" : "primary small"}
|
type="button"
|
||||||
onClick={() => {
|
className={isActive ? "secondary small" : "primary small"}
|
||||||
setExpandedResultCode(result.code);
|
onClick={() => {
|
||||||
onApplyResult(result);
|
setExpandedResultCode(displayKey);
|
||||||
}}
|
onApplyResult(representative);
|
||||||
disabled={state.applyingCode === result.code}
|
}}
|
||||||
>
|
disabled={isActive && state.applyingCode !== null}
|
||||||
{applyLabel}
|
>
|
||||||
</button>
|
{applyLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDetails && isDetailsExpanded ? (
|
{hasDetails && isDetailsExpanded ? (
|
||||||
<dl className="medication-enrichment-result-meta">
|
<dl className="medication-enrichment-result-meta">
|
||||||
{result.authorisationHolder ? (
|
{authorisationHolder ? (
|
||||||
<div>
|
<div>
|
||||||
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
||||||
<dd>{result.authorisationHolder}</dd>
|
<dd>{authorisationHolder}</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{result.therapeuticArea ? (
|
{therapeuticArea ? (
|
||||||
<div>
|
<div>
|
||||||
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
||||||
<dd>{result.therapeuticArea}</dd>
|
<dd>{therapeuticArea}</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{result.authorisationDate ? (
|
{authorisationDate ? (
|
||||||
<div>
|
<div>
|
||||||
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
||||||
<dd>{formatDate(result.authorisationDate)}</dd>
|
<dd>{formatDate(authorisationDate)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activePackageOptions.length > 0 ? (
|
||||||
|
<div className="medication-enrichment-result-meta-full">
|
||||||
|
<dt>{t("form.enrichment.details.packageSizes")}</dt>
|
||||||
|
<dd>
|
||||||
|
<div className="medication-enrichment-detail-stack">
|
||||||
|
{showInlinePackageChoices ? (
|
||||||
|
<div className="medication-enrichment-strength-list medication-enrichment-package-choice-list">
|
||||||
|
{activePackageOptions.map((option) => {
|
||||||
|
const isApplyingPending =
|
||||||
|
isApplyingPackageSelection && state.applyingPackageLabel === option.label;
|
||||||
|
const isSelected =
|
||||||
|
isActive &&
|
||||||
|
(state.appliedPackageLabel === option.label ||
|
||||||
|
(appliedPackageOption !== null &&
|
||||||
|
buildPackageOptionKey(appliedPackageOption) ===
|
||||||
|
buildPackageOptionKey(option)));
|
||||||
|
const packageLabel = formatPackageOptionDisplayText(option, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.label}
|
||||||
|
type="button"
|
||||||
|
className={`medication-enrichment-package-choice-button ${isSelected || isApplyingPending ? "primary small" : "secondary small"}${isApplyingPending ? " is-loading" : ""}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
title={packageLabel}
|
||||||
|
onClick={() =>
|
||||||
|
isActive && uniqueStatePackageOptions.length > 0
|
||||||
|
? onApplyPackage(option)
|
||||||
|
: onApplyResult(
|
||||||
|
packageChoices.find((choice) => choice.option.label === option.label)
|
||||||
|
?.sourceResult ?? representative,
|
||||||
|
option
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isActive && state.applyingCode !== null}
|
||||||
|
>
|
||||||
|
{isApplyingPending ? (
|
||||||
|
<span className="medication-enrichment-spinner" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
<span>{packageLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="medication-enrichment-package-details">
|
||||||
|
{activePackageOptions.map((option) => (
|
||||||
|
<li key={option.label}>{formatPackageOptionDisplayText(option, t)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{isActive && state.appliedPackageLabel ? (
|
||||||
|
<p className="success-text medication-enrichment-applied-note">
|
||||||
|
{t("form.enrichment.appliedPackage", {
|
||||||
|
label: formatPackageOptionDisplayText(
|
||||||
|
appliedPackageOption ?? state.appliedPackageLabel,
|
||||||
|
t
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isApplyingPackageSelection ? (
|
||||||
|
<div className="medication-enrichment-result-meta-full">
|
||||||
|
<dt>{t("form.enrichment.strengthTitle")}</dt>
|
||||||
|
<dd>
|
||||||
|
<div className="medication-enrichment-pending-panel" aria-live="polite">
|
||||||
|
<span className="medication-enrichment-spinner" aria-hidden="true" />
|
||||||
|
<span>{t("form.enrichment.applying")}</span>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasActiveStrengthOptions ? (
|
||||||
|
<div className="medication-enrichment-result-meta-full">
|
||||||
|
<dt>{t("form.enrichment.strengthTitle")}</dt>
|
||||||
|
<dd>
|
||||||
|
<div className="medication-enrichment-detail-stack">
|
||||||
|
<p className="sub medication-enrichment-detail-hint">
|
||||||
|
{t("form.enrichment.strengthHint")}
|
||||||
|
</p>
|
||||||
|
<div className="medication-enrichment-strength-list">
|
||||||
|
{visibleStrengthOptions.map((option) => {
|
||||||
|
const isSelected = state.appliedStrengthLabel === option.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.label}
|
||||||
|
type="button"
|
||||||
|
className={isSelected ? "primary small" : "secondary small"}
|
||||||
|
onClick={() => onApplyStrength(option)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hasMoreStrengthOptions ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary small medication-enrichment-inline-action"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleStrengthOptionCount(
|
||||||
|
(current) => current + INITIAL_VISIBLE_STRENGTH_OPTIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("form.enrichment.showMoreStrengthsAction")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{state.appliedStrengthLabel ? (
|
||||||
|
<p className="success-text medication-enrichment-applied-note">
|
||||||
|
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -221,61 +630,21 @@ export function MedicationEnrichmentSection({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
|
{showLoadMoreAction ? (
|
||||||
<div className="medication-enrichment-results-footer">
|
<div className="medication-enrichment-results-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary small"
|
className={`secondary small medication-enrichment-action-button medication-enrichment-load-more-button${isLoadingMoreResults ? " is-loading" : ""}`}
|
||||||
onClick={onLoadMoreResults}
|
onClick={onLoadMoreResults}
|
||||||
disabled={state.isSearching || Boolean(state.applyingCode)}
|
disabled={state.isSearching || Boolean(state.applyingCode)}
|
||||||
>
|
>
|
||||||
{t("form.enrichment.showMoreAction")}
|
{isLoadingMoreResults ? <span className="medication-enrichment-spinner" aria-hidden="true" /> : null}
|
||||||
|
<span>
|
||||||
|
{isLoadingMoreResults ? t("form.enrichment.loadingMoreResults") : t("form.enrichment.showMoreAction")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
|
|
||||||
<div className="medication-enrichment-followup">
|
|
||||||
{state.appliedSelection ? (
|
|
||||||
<div>
|
|
||||||
<p className="success-text">{t("form.enrichment.applied")}</p>
|
|
||||||
<p className="sub medication-enrichment-selection-summary">
|
|
||||||
<strong>{state.appliedSelection.name}</strong>
|
|
||||||
{state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.strengthOptions.length > 0 ? (
|
|
||||||
<div className="medication-enrichment-strengths">
|
|
||||||
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
|
|
||||||
<p className="sub">{t("form.enrichment.strengthHint")}</p>
|
|
||||||
<div className="medication-enrichment-strength-list">
|
|
||||||
{state.strengthOptions.map((option) => {
|
|
||||||
const isSelected = state.appliedStrengthLabel === option.label;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.label}
|
|
||||||
type="button"
|
|
||||||
className={isSelected ? "primary small" : "secondary small"}
|
|
||||||
onClick={() => onApplyStrength(option)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.appliedStrengthLabel ? (
|
|
||||||
<p className="success-text">
|
|
||||||
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
FormIntake,
|
FormIntake,
|
||||||
FormState,
|
FormState,
|
||||||
Medication,
|
Medication,
|
||||||
|
MedicationEnrichmentPackageOption,
|
||||||
MedicationEnrichmentSearchResult,
|
MedicationEnrichmentSearchResult,
|
||||||
MedicationEnrichmentStrengthOption,
|
MedicationEnrichmentStrengthOption,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
@@ -59,12 +60,15 @@ const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = {
|
|||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
searchError: null,
|
searchError: null,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
activeResultCode: null,
|
activeResultCode: null,
|
||||||
appliedSelection: null,
|
appliedSelection: null,
|
||||||
enrichError: null,
|
enrichError: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MobileEditModalProps {
|
export interface MobileEditModalProps {
|
||||||
@@ -76,8 +80,12 @@ export interface MobileEditModalProps {
|
|||||||
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
||||||
onMedicationEnrichmentSearch?: () => void;
|
onMedicationEnrichmentSearch?: () => void;
|
||||||
onMedicationEnrichmentLoadMore?: () => void;
|
onMedicationEnrichmentLoadMore?: () => void;
|
||||||
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
|
onMedicationEnrichmentApply?: (
|
||||||
|
result: MedicationEnrichmentSearchResult,
|
||||||
|
preferredPackageOption?: MedicationEnrichmentPackageOption
|
||||||
|
) => void;
|
||||||
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
||||||
|
onMedicationEnrichmentPackageApply?: (option: MedicationEnrichmentPackageOption) => void;
|
||||||
fieldErrors: FieldErrors;
|
fieldErrors: FieldErrors;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
formSaved: boolean;
|
formSaved: boolean;
|
||||||
@@ -136,6 +144,7 @@ export function MobileEditModal({
|
|||||||
onMedicationEnrichmentLoadMore = () => {},
|
onMedicationEnrichmentLoadMore = () => {},
|
||||||
onMedicationEnrichmentApply = () => {},
|
onMedicationEnrichmentApply = () => {},
|
||||||
onMedicationEnrichmentStrengthApply = () => {},
|
onMedicationEnrichmentStrengthApply = () => {},
|
||||||
|
onMedicationEnrichmentPackageApply = () => {},
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
saving,
|
saving,
|
||||||
formSaved,
|
formSaved,
|
||||||
@@ -492,6 +501,7 @@ export function MobileEditModal({
|
|||||||
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
||||||
onApplyResult={onMedicationEnrichmentApply}
|
onApplyResult={onMedicationEnrichmentApply}
|
||||||
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
||||||
|
onApplyPackage={onMedicationEnrichmentPackageApply}
|
||||||
/>
|
/>
|
||||||
<div className="full date-pair-group">
|
<div className="full date-pair-group">
|
||||||
<label className="date-pair-field">
|
<label className="date-pair-field">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useScrollLock } from "../hooks/useScrollLock";
|
|||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
import {
|
import {
|
||||||
getMedDisplayName,
|
getMedDisplayName,
|
||||||
getPackageSize,
|
getMedTotal,
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
@@ -313,9 +313,9 @@ function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
|||||||
|
|
||||||
function getCurrentStockText(med: Medication, t: TFn): string {
|
function getCurrentStockText(med: Medication, t: TFn): string {
|
||||||
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
return `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
|
||||||
}
|
}
|
||||||
return `${getPackageSize(med)} ${t("common.pills")}`;
|
return `${getMedTotal(med)} ${t("common.pills")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
|
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
|
||||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
import { getMedDisplayName, getMedTotal, getStockDisplayCapacity } from "../types";
|
||||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
||||||
import { formatNumber } from "../utils";
|
import { formatNumber } from "../utils";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
@@ -99,7 +99,7 @@ export function UserFilterModal({
|
|||||||
const status = medCoverage
|
const status = medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||||
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||||
const packageSize = getPackageSize(med);
|
const packageSize = getStockDisplayCapacity(med);
|
||||||
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
||||||
|
|
||||||
// Get intakes relevant to this person
|
// Get intakes relevant to this person
|
||||||
|
|||||||
@@ -70,12 +70,16 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||||
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
||||||
|
|
||||||
const clearRefillState = useCallback(() => {
|
const resetRefillForm = useCallback(() => {
|
||||||
setShowRefillModal(false);
|
|
||||||
setRefillPacks(1);
|
setRefillPacks(1);
|
||||||
setRefillLoose(0);
|
setRefillLoose(0);
|
||||||
setUsePrescriptionRefill(false);
|
setUsePrescriptionRefill(false);
|
||||||
setRefillSaving(false);
|
setRefillSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearRefillState = useCallback(() => {
|
||||||
|
setShowRefillModal(false);
|
||||||
|
resetRefillForm();
|
||||||
setRefillHistory([]);
|
setRefillHistory([]);
|
||||||
setRefillHistoryExpanded(false);
|
setRefillHistoryExpanded(false);
|
||||||
setShowEditStockModal(false);
|
setShowEditStockModal(false);
|
||||||
@@ -84,7 +88,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
setEditStockLoosePills(0);
|
setEditStockLoosePills(0);
|
||||||
setEditStockSaving(false);
|
setEditStockSaving(false);
|
||||||
setEditStockMedication(null);
|
setEditStockMedication(null);
|
||||||
}, []);
|
}, [resetRefillForm]);
|
||||||
|
|
||||||
// Load refill history for a medication
|
// Load refill history for a medication
|
||||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||||
@@ -190,9 +194,11 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const structuralMax = isAmountPackage
|
const structuralMax = isAmountPackage
|
||||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
const correctedLiquidBottleCount = isLiquidPackage
|
const isZeroReset = finalFullBlisters === 0 && finalPartialPills === 0 && finalLoosePills === 0;
|
||||||
? Math.max(1, finalFullBlisters)
|
let correctedLiquidBottleCount = Math.max(0, selectedMed.packCount);
|
||||||
: Math.max(1, selectedMed.packCount);
|
if (isLiquidPackage) {
|
||||||
|
correctedLiquidBottleCount = isZeroReset ? 0 : Math.max(1, finalFullBlisters);
|
||||||
|
}
|
||||||
const liquidStructuralMax = isLiquidPackage
|
const liquidStructuralMax = isLiquidPackage
|
||||||
? correctedLiquidBottleCount * liquidAmountPerBottle
|
? correctedLiquidBottleCount * liquidAmountPerBottle
|
||||||
: structuralMax;
|
: structuralMax;
|
||||||
@@ -217,8 +223,10 @@ export function useRefill(): UseRefillReturn {
|
|||||||
let baseTotal: number;
|
let baseTotal: number;
|
||||||
if (isLiquidPackage) {
|
if (isLiquidPackage) {
|
||||||
baseTotal = liquidStructuralMax;
|
baseTotal = liquidStructuralMax;
|
||||||
|
} else if (selectedMed.packageType === "bottle") {
|
||||||
|
baseTotal = selectedMed.looseTablets;
|
||||||
} else if (isAmountPackage) {
|
} else if (isAmountPackage) {
|
||||||
baseTotal = getPackageSize(selectedMed); // bottle: stockAdjustment relative to fixed looseTablets base
|
baseTotal = getPackageSize(selectedMed);
|
||||||
} else {
|
} else {
|
||||||
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||||
}
|
}
|
||||||
@@ -236,7 +244,17 @@ export function useRefill(): UseRefillReturn {
|
|||||||
} = {
|
} = {
|
||||||
stockAdjustment: newStockAdjustment,
|
stockAdjustment: newStockAdjustment,
|
||||||
};
|
};
|
||||||
if (isTubePackage) {
|
if (isZeroReset) {
|
||||||
|
patchBody.stockAdjustment = 0;
|
||||||
|
patchBody.packCount = 0;
|
||||||
|
patchBody.looseTablets = 0;
|
||||||
|
if (selectedMed.packageType === "bottle" || isAmountPackage) {
|
||||||
|
patchBody.totalPills = 0;
|
||||||
|
}
|
||||||
|
if (isTubePackage || isLiquidPackage) {
|
||||||
|
patchBody.packageAmountValue = 0;
|
||||||
|
}
|
||||||
|
} else if (isTubePackage) {
|
||||||
// Tube has fixed count=1 and no automatic depletion.
|
// Tube has fixed count=1 and no automatic depletion.
|
||||||
// Correction must update the base amount fields directly.
|
// Correction must update the base amount fields directly.
|
||||||
patchBody.stockAdjustment = 0;
|
patchBody.stockAdjustment = 0;
|
||||||
@@ -277,9 +295,10 @@ export function useRefill(): UseRefillReturn {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openRefillModal = useCallback(() => {
|
const openRefillModal = useCallback(() => {
|
||||||
|
resetRefillForm();
|
||||||
setShowRefillModal(true);
|
setShowRefillModal(true);
|
||||||
window.history.pushState({ modal: "refill" }, "");
|
window.history.pushState({ modal: "refill" }, "");
|
||||||
}, []);
|
}, [resetRefillForm]);
|
||||||
|
|
||||||
const closeRefillModal = useCallback(() => {
|
const closeRefillModal = useCallback(() => {
|
||||||
if (showRefillModal) {
|
if (showRefillModal) {
|
||||||
|
|||||||
@@ -228,35 +228,65 @@
|
|||||||
"enrichment": {
|
"enrichment": {
|
||||||
"title": "Optionale Medikamentensuche",
|
"title": "Optionale Medikamentensuche",
|
||||||
"coverageLabel": "Unvollständige freie Abdeckung",
|
"coverageLabel": "Unvollständige freie Abdeckung",
|
||||||
"collapsedHint": "Öffne das nur, wenn du Suchvorschläge nutzen möchtest.",
|
"collapsedHint": "Nur verwenden, wenn du Suchvorschläge brauchst.",
|
||||||
"toggleShow": "Suche anzeigen",
|
"toggleShow": "Suche anzeigen",
|
||||||
"toggleHide": "Suche ausblenden",
|
"toggleHide": "Suche ausblenden",
|
||||||
"infoShow": "Infos zu den Quellen",
|
"infoShow": "Infos zu den Quellen",
|
||||||
"infoHide": "Quellenhinweise ausblenden",
|
"infoHide": "Quellenhinweise ausblenden",
|
||||||
"infoTitle": "Was du erwarten kannst",
|
"infoTitle": "Hinweise zu den Quellen",
|
||||||
"description": "Durchsuche zuerst RxNorm und openFDA, nutze EMA nur als letzten Fallback und prüfe jeden Treffer, bevor du etwas ins Formular übernimmst.",
|
"description": "Die Ergebnisse stammen in erster Linie aus RxNorm und openFDA. EMA wird nur als Fallback verwendet, wenn es nötig ist. Prüfe jeden Vorschlag, bevor du ihn ins Formular übernimmst.",
|
||||||
"searchLabel": "Medikamentenquellen durchsuchen",
|
"searchLabel": "Medikamentenquellen durchsuchen",
|
||||||
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
|
"searchPlaceholder": "Nach Marke oder Wirkstoff suchen",
|
||||||
"searchAction": "Suchen",
|
"searchAction": "Suchen",
|
||||||
"searching": "Suche läuft...",
|
"searching": "Suche läuft...",
|
||||||
|
"loadingSearch": "Medikamentenquellen werden durchsucht...",
|
||||||
|
"loadingMoreResults": "Weitere Treffer werden geladen...",
|
||||||
"showMoreAction": "Mehr Treffer anzeigen",
|
"showMoreAction": "Mehr Treffer anzeigen",
|
||||||
"noResults": "Es wurden in der aktuellen Freiquellen-Suche keine Treffer gefunden. Du kannst das Medikament manuell weiter erfassen.",
|
"noResults": "Es wurden in der aktuellen Freiquellen-Suche keine Treffer gefunden. Du kannst das Medikament manuell weiter erfassen.",
|
||||||
"manualEntryHint": "Diese Hilfe ist optional und kann Medikamente, Stärken oder lokale Marktvarianten übersehen.",
|
"manualEntryHint": "Die Suchvorschläge sind optional und decken möglicherweise nicht jedes Medikament, jede Stärke, jede Packungsgröße oder jede lokale Marktvariante ab.",
|
||||||
|
"authRequired": "Für die Medikamentensuche ist eine aktive Anmeldung erforderlich. Bitte melde dich erneut an oder fahre mit der manuellen Eingabe fort.",
|
||||||
"searchError": "Die Medikamentensuche ist momentan nicht verfügbar. Bitte fahre mit der manuellen Eingabe fort.",
|
"searchError": "Die Medikamentensuche ist momentan nicht verfügbar. Bitte fahre mit der manuellen Eingabe fort.",
|
||||||
"applyAction": "Übernehmen",
|
"applyAction": "Übernehmen",
|
||||||
"applying": "Wird übernommen...",
|
"applying": "Wird übernommen...",
|
||||||
"applied": "Ins Formular übernommen",
|
"applied": "Ins Formular übernommen",
|
||||||
"applyError": "Das Autofill konnte nicht übernommen werden. Bitte bearbeite das Medikament manuell weiter.",
|
"applyError": "Das Autofill konnte nicht übernommen werden. Bitte bearbeite das Medikament manuell weiter.",
|
||||||
"partialNote": "Es waren nur teilweise Autofill-Vorschläge verfügbar. Prüfe die Felder vor dem Speichern.",
|
"partialNote": "Es waren nur teilweise Autofill-Vorschläge verfügbar. Prüfe die Felder vor dem Speichern.",
|
||||||
|
"packageAvailable": "Packungsgröße",
|
||||||
|
"packageUnavailable": "Keine Packungsgröße",
|
||||||
|
"packageTitle": "Packungsgrößen-Vorschläge",
|
||||||
|
"packageHint": "Wähle eine Packungsgröße aus, um die Bestandsfelder zu aktualisieren.",
|
||||||
|
"appliedPackage": "Übernommene Packungsgröße: {{label}}",
|
||||||
|
"packageContainers": {
|
||||||
|
"blister_one": "1 Blisterpackung",
|
||||||
|
"blister_other": "{{count}} Blisterpackungen",
|
||||||
|
"bottle_one": "1 Flasche",
|
||||||
|
"bottle_other": "{{count}} Flaschen",
|
||||||
|
"liquidContainer_one": "1 Flasche",
|
||||||
|
"liquidContainer_other": "{{count}} Flaschen",
|
||||||
|
"tube_one": "1 Tube",
|
||||||
|
"tube_other": "{{count}} Tuben"
|
||||||
|
},
|
||||||
|
"packageUnits": {
|
||||||
|
"tablet_one": "Tablette",
|
||||||
|
"tablet_other": "Tabletten",
|
||||||
|
"capsule_one": "Kapsel",
|
||||||
|
"capsule_other": "Kapseln",
|
||||||
|
"caplet_one": "Caplet",
|
||||||
|
"caplet_other": "Caplets",
|
||||||
|
"pill_one": "Pille",
|
||||||
|
"pill_other": "Pillen"
|
||||||
|
},
|
||||||
"strengthTitle": "Stärke-Vorschläge",
|
"strengthTitle": "Stärke-Vorschläge",
|
||||||
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
|
"strengthHint": "Wähle eine Stärke aus, um Dosis pro Tablette und Einheit zu aktualisieren.",
|
||||||
|
"showMoreStrengthsAction": "Mehr anzeigen",
|
||||||
"appliedStrength": "Übernommene Stärke: {{label}}",
|
"appliedStrength": "Übernommene Stärke: {{label}}",
|
||||||
"details": {
|
"details": {
|
||||||
"showAction": "Mehr Details",
|
"showAction": "Mehr Details",
|
||||||
"hideAction": "Weniger Details",
|
"hideAction": "Weniger Details",
|
||||||
"authorisationHolder": "Zulassungsinhaber",
|
"authorisationHolder": "Zulassungsinhaber",
|
||||||
"therapeuticArea": "Therapiebereich",
|
"therapeuticArea": "Therapiebereich",
|
||||||
"authorisationDate": "Zulassungsdatum"
|
"authorisationDate": "Zulassungsdatum",
|
||||||
|
"packageSizes": "Packungsgrößen"
|
||||||
},
|
},
|
||||||
"genericStatus": {
|
"genericStatus": {
|
||||||
"generic": "Generikum",
|
"generic": "Generikum",
|
||||||
|
|||||||
@@ -228,35 +228,65 @@
|
|||||||
"enrichment": {
|
"enrichment": {
|
||||||
"title": "Optional medication lookup",
|
"title": "Optional medication lookup",
|
||||||
"coverageLabel": "Incomplete free-source coverage",
|
"coverageLabel": "Incomplete free-source coverage",
|
||||||
"collapsedHint": "Open this only if you want lookup suggestions.",
|
"collapsedHint": "Use this only when you want lookup suggestions.",
|
||||||
"toggleShow": "Show lookup",
|
"toggleShow": "Show lookup",
|
||||||
"toggleHide": "Hide lookup",
|
"toggleHide": "Hide lookup",
|
||||||
"infoShow": "About sources",
|
"infoShow": "About sources",
|
||||||
"infoHide": "Hide source notes",
|
"infoHide": "Hide source notes",
|
||||||
"infoTitle": "What to expect",
|
"infoTitle": "Source notes",
|
||||||
"description": "Search RxNorm and openFDA first, use EMA as a last fallback, and review each result before applying anything to the form.",
|
"description": "Results are primarily sourced from RxNorm and openFDA. EMA is used only as a fallback when needed. Review each suggestion before applying it to the form.",
|
||||||
"searchLabel": "Search medication sources",
|
"searchLabel": "Search medication sources",
|
||||||
"searchPlaceholder": "Search by brand or ingredient",
|
"searchPlaceholder": "Search by brand or ingredient",
|
||||||
"searchAction": "Search",
|
"searchAction": "Search",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
|
"loadingSearch": "Searching medication sources...",
|
||||||
|
"loadingMoreResults": "Loading more results...",
|
||||||
"showMoreAction": "Show more results",
|
"showMoreAction": "Show more results",
|
||||||
"noResults": "No matches were found in the current free-source search. You can continue entering the medication manually.",
|
"noResults": "No matches were found in the current free-source search. You can continue entering the medication manually.",
|
||||||
"manualEntryHint": "This helper is optional and may miss medications, strengths, or local market variants.",
|
"manualEntryHint": "Lookup suggestions are optional and may not cover every medication, strength, package size, or local market variant.",
|
||||||
|
"authRequired": "Medication lookup requires an active sign-in. Please sign in again or continue with manual entry.",
|
||||||
"searchError": "Medication lookup is currently unavailable. Please continue with manual entry.",
|
"searchError": "Medication lookup is currently unavailable. Please continue with manual entry.",
|
||||||
"applyAction": "Apply",
|
"applyAction": "Apply",
|
||||||
"applying": "Applying...",
|
"applying": "Applying...",
|
||||||
"applied": "Applied to form",
|
"applied": "Applied to form",
|
||||||
"applyError": "Autofill could not be applied. Please keep editing the medication manually.",
|
"applyError": "Autofill could not be applied. Please keep editing the medication manually.",
|
||||||
"partialNote": "Only partial autofill suggestions were available. Review the fields before saving.",
|
"partialNote": "Only partial autofill suggestions were available. Review the fields before saving.",
|
||||||
|
"packageAvailable": "Package size",
|
||||||
|
"packageUnavailable": "No package size",
|
||||||
|
"packageTitle": "Package size suggestions",
|
||||||
|
"packageHint": "Choose a package size to update the stock fields.",
|
||||||
|
"appliedPackage": "Applied package size: {{label}}",
|
||||||
|
"packageContainers": {
|
||||||
|
"blister_one": "1 blister pack",
|
||||||
|
"blister_other": "{{count}} blister packs",
|
||||||
|
"bottle_one": "1 bottle",
|
||||||
|
"bottle_other": "{{count}} bottles",
|
||||||
|
"liquidContainer_one": "1 bottle",
|
||||||
|
"liquidContainer_other": "{{count}} bottles",
|
||||||
|
"tube_one": "1 tube",
|
||||||
|
"tube_other": "{{count}} tubes"
|
||||||
|
},
|
||||||
|
"packageUnits": {
|
||||||
|
"tablet_one": "tablet",
|
||||||
|
"tablet_other": "tablets",
|
||||||
|
"capsule_one": "capsule",
|
||||||
|
"capsule_other": "capsules",
|
||||||
|
"caplet_one": "caplet",
|
||||||
|
"caplet_other": "caplets",
|
||||||
|
"pill_one": "pill",
|
||||||
|
"pill_other": "pills"
|
||||||
|
},
|
||||||
"strengthTitle": "Strength suggestions",
|
"strengthTitle": "Strength suggestions",
|
||||||
"strengthHint": "Choose a strength to update dose per pill and unit.",
|
"strengthHint": "Choose a strength to update dose per pill and unit.",
|
||||||
|
"showMoreStrengthsAction": "Show more",
|
||||||
"appliedStrength": "Applied strength: {{label}}",
|
"appliedStrength": "Applied strength: {{label}}",
|
||||||
"details": {
|
"details": {
|
||||||
"showAction": "More details",
|
"showAction": "More details",
|
||||||
"hideAction": "Less details",
|
"hideAction": "Less details",
|
||||||
"authorisationHolder": "Authorisation holder",
|
"authorisationHolder": "Authorisation holder",
|
||||||
"therapeuticArea": "Therapeutic area",
|
"therapeuticArea": "Therapeutic area",
|
||||||
"authorisationDate": "Authorisation date"
|
"authorisationDate": "Authorisation date",
|
||||||
|
"packageSizes": "Package sizes"
|
||||||
},
|
},
|
||||||
"genericStatus": {
|
"genericStatus": {
|
||||||
"generic": "Generic",
|
"generic": "Generic",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
FormState,
|
FormState,
|
||||||
Medication,
|
Medication,
|
||||||
MedicationEnrichmentEnrichResponse,
|
MedicationEnrichmentEnrichResponse,
|
||||||
|
MedicationEnrichmentPackageOption,
|
||||||
MedicationEnrichmentSearchResponse,
|
MedicationEnrichmentSearchResponse,
|
||||||
MedicationEnrichmentSearchResult,
|
MedicationEnrichmentSearchResult,
|
||||||
MedicationEnrichmentStrengthOption,
|
MedicationEnrichmentStrengthOption,
|
||||||
@@ -33,8 +34,10 @@ import {
|
|||||||
DOSE_UNITS,
|
DOSE_UNITS,
|
||||||
FIELD_LIMITS,
|
FIELD_LIMITS,
|
||||||
getMedDisplayName,
|
getMedDisplayName,
|
||||||
|
getMedTotal,
|
||||||
getPackageProfile,
|
getPackageProfile,
|
||||||
getPackageSize,
|
getPackageSize,
|
||||||
|
getStockDisplayCapacity,
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
@@ -53,6 +56,7 @@ import {
|
|||||||
WEEKDAY_CODES,
|
WEEKDAY_CODES,
|
||||||
} from "../utils/intake-schedule";
|
} from "../utils/intake-schedule";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
import { countMedicationEnrichmentDisplayResults } from "../utils/medication-enrichment";
|
||||||
|
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
return userId ? `user_${userId}_${key}` : key;
|
return userId ? `user_${userId}_${key}` : key;
|
||||||
@@ -62,6 +66,7 @@ const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
|||||||
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
|
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
|
||||||
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
|
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
|
||||||
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
|
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
|
||||||
|
const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s*/gi;
|
||||||
|
|
||||||
type MedicationEnrichmentState = {
|
type MedicationEnrichmentState = {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -72,12 +77,15 @@ type MedicationEnrichmentState = {
|
|||||||
hasSearched: boolean;
|
hasSearched: boolean;
|
||||||
searchError: string | null;
|
searchError: string | null;
|
||||||
applyingCode: string | null;
|
applyingCode: string | null;
|
||||||
|
applyingPackageLabel: string | null;
|
||||||
activeResultCode: string | null;
|
activeResultCode: string | null;
|
||||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||||
enrichError: string | null;
|
enrichError: string | null;
|
||||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
appliedStrengthLabel: string | null;
|
appliedStrengthLabel: string | null;
|
||||||
|
appliedPackageLabel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMedicationEnrichmentState(
|
function createMedicationEnrichmentState(
|
||||||
@@ -93,12 +101,15 @@ function createMedicationEnrichmentState(
|
|||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
searchError: null,
|
searchError: null,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
activeResultCode: null,
|
activeResultCode: null,
|
||||||
appliedSelection: null,
|
appliedSelection: null,
|
||||||
enrichError: null,
|
enrichError: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +119,40 @@ function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengt
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMedicationEnrichmentPackageText(value: string): string {
|
||||||
|
return value.replace(OPEN_FDA_PACKAGE_CODE_PATTERN, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMatchingMedicationEnrichmentPackageStructure(
|
||||||
|
left: MedicationEnrichmentPackageOption,
|
||||||
|
right: MedicationEnrichmentPackageOption
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.packageType === right.packageType &&
|
||||||
|
left.packCount === right.packCount &&
|
||||||
|
left.blistersPerPack === right.blistersPerPack &&
|
||||||
|
left.pillsPerBlister === right.pillsPerBlister &&
|
||||||
|
left.totalPills === right.totalPills &&
|
||||||
|
left.looseTablets === right.looseTablets &&
|
||||||
|
left.packageAmountValue === right.packageAmountValue &&
|
||||||
|
left.packageAmountUnit === right.packageAmountUnit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMedicationEnrichmentPackageOption(
|
||||||
|
left: MedicationEnrichmentPackageOption,
|
||||||
|
right: MedicationEnrichmentPackageOption
|
||||||
|
): boolean {
|
||||||
|
const leftTexts = [left.label, left.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean);
|
||||||
|
const rightTexts = [right.label, right.description].map(normalizeMedicationEnrichmentPackageText).filter(Boolean);
|
||||||
|
const hasMatchingText = leftTexts.some((text) => rightTexts.includes(text));
|
||||||
|
|
||||||
|
return (
|
||||||
|
hasMatchingMedicationEnrichmentPackageStructure(left, right) ||
|
||||||
|
(hasMatchingText && left.packageType === right.packageType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyMedicationEnrichmentSuggestions(
|
function applyMedicationEnrichmentSuggestions(
|
||||||
form: FormState,
|
form: FormState,
|
||||||
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
|
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
|
||||||
@@ -151,7 +196,53 @@ function applyMedicationEnrichmentStrength(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
|
function applyMedicationEnrichmentPackage(form: FormState, option: MedicationEnrichmentPackageOption): FormState {
|
||||||
|
const nextForm: FormState = {
|
||||||
|
...form,
|
||||||
|
packageType: option.packageType,
|
||||||
|
packCount: `${option.packCount}`,
|
||||||
|
blistersPerPack: option.blistersPerPack !== null ? `${option.blistersPerPack}` : "1",
|
||||||
|
pillsPerBlister: option.pillsPerBlister !== null ? `${option.pillsPerBlister}` : "1",
|
||||||
|
packageAmountValue: option.packageAmountValue !== null ? `${option.packageAmountValue}` : "",
|
||||||
|
packageAmountUnit: option.packageAmountUnit ?? form.packageAmountUnit,
|
||||||
|
totalPills: option.totalPills !== null ? `${option.totalPills}` : "",
|
||||||
|
looseTablets: option.looseTablets !== null ? `${option.looseTablets}` : "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (option.packageType === "blister") {
|
||||||
|
return {
|
||||||
|
...nextForm,
|
||||||
|
totalPills: "",
|
||||||
|
looseTablets: "0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.packageType === "liquid_container") {
|
||||||
|
return {
|
||||||
|
...nextForm,
|
||||||
|
medicationForm: "liquid",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.packageType === "tube") {
|
||||||
|
return {
|
||||||
|
...nextForm,
|
||||||
|
medicationForm: "topical",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMedicationEnrichmentErrorMessage(
|
||||||
|
response: Response,
|
||||||
|
fallback: string,
|
||||||
|
unauthorizedFallback: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (response.status === 401) {
|
||||||
|
return unauthorizedFallback;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errorBody = (await response.json()) as { error?: string; message?: string };
|
const errorBody = (await response.json()) as { error?: string; message?: string };
|
||||||
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
|
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
|
||||||
@@ -282,12 +373,15 @@ export function MedicationsPage() {
|
|||||||
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
|
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
|
||||||
createMedicationEnrichmentState()
|
createMedicationEnrichmentState()
|
||||||
);
|
);
|
||||||
|
const medicationEnrichmentQueryRef = useRef("");
|
||||||
|
|
||||||
const resetMedicationEnrichment = useCallback((query = "") => {
|
const resetMedicationEnrichment = useCallback((query = "") => {
|
||||||
|
medicationEnrichmentQueryRef.current = query;
|
||||||
setMedicationEnrichment(createMedicationEnrichmentState(query));
|
setMedicationEnrichment(createMedicationEnrichmentState(query));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
|
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
|
||||||
|
medicationEnrichmentQueryRef.current = value;
|
||||||
setMedicationEnrichment((previous) => ({
|
setMedicationEnrichment((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
query: value,
|
query: value,
|
||||||
@@ -295,21 +389,28 @@ export function MedicationsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const performMedicationEnrichmentSearch = useCallback(
|
const performMedicationEnrichmentSearch = useCallback(
|
||||||
async (requestedLimit: number, preserveExistingResults = false) => {
|
async (
|
||||||
const trimmedQuery = medicationEnrichment.query.trim();
|
requestedLimit: number,
|
||||||
|
preserveExistingResults = false,
|
||||||
|
previousVisibleResultCount = countMedicationEnrichmentDisplayResults(medicationEnrichment.results),
|
||||||
|
queryOverride?: string
|
||||||
|
) => {
|
||||||
|
const trimmedQuery = (queryOverride ?? medicationEnrichmentQueryRef.current).trim();
|
||||||
if (!trimmedQuery) return;
|
if (!trimmedQuery) return;
|
||||||
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
|
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
|
||||||
|
medicationEnrichmentQueryRef.current = trimmedQuery;
|
||||||
|
|
||||||
setMedicationEnrichment((previous) => ({
|
setMedicationEnrichment((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
query: trimmedQuery,
|
query: trimmedQuery,
|
||||||
results: preserveExistingResults ? previous.results : [],
|
results: preserveExistingResults ? previous.results : [],
|
||||||
hasMoreResults: false,
|
hasMoreResults: preserveExistingResults ? previous.hasMoreResults : false,
|
||||||
resultLimit: limit,
|
resultLimit: limit,
|
||||||
isSearching: true,
|
isSearching: true,
|
||||||
hasSearched: preserveExistingResults ? previous.hasSearched : false,
|
hasSearched: preserveExistingResults ? previous.hasSearched : false,
|
||||||
searchError: null,
|
searchError: null,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
...(preserveExistingResults
|
...(preserveExistingResults
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
@@ -318,7 +419,9 @@ export function MedicationsPage() {
|
|||||||
enrichError: null,
|
enrichError: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -329,21 +432,50 @@ export function MedicationsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
|
throw new Error(
|
||||||
|
await getMedicationEnrichmentErrorMessage(
|
||||||
|
response,
|
||||||
|
t("form.enrichment.searchError"),
|
||||||
|
t("form.enrichment.authRequired")
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
|
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
|
||||||
|
const nextResults = Array.isArray(data.results) ? data.results : [];
|
||||||
|
const nextVisibleResultCount = countMedicationEnrichmentDisplayResults(nextResults);
|
||||||
|
const reachedResultLimitCap = limit >= MEDICATION_ENRICHMENT_MAX_LIMIT;
|
||||||
|
const shouldLoadUntilVisibleResultChanges =
|
||||||
|
preserveExistingResults &&
|
||||||
|
Boolean(data.hasMore) &&
|
||||||
|
!reachedResultLimitCap &&
|
||||||
|
nextVisibleResultCount <= previousVisibleResultCount;
|
||||||
|
|
||||||
setMedicationEnrichment((previous) => ({
|
if (shouldLoadUntilVisibleResultChanges) {
|
||||||
...previous,
|
await performMedicationEnrichmentSearch(
|
||||||
query: data.query,
|
Math.min(limit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||||
results: Array.isArray(data.results) ? data.results : [],
|
true,
|
||||||
hasMoreResults: Boolean(data.hasMore),
|
previousVisibleResultCount,
|
||||||
resultLimit: limit,
|
trimmedQuery
|
||||||
isSearching: false,
|
);
|
||||||
hasSearched: true,
|
return;
|
||||||
searchError: null,
|
}
|
||||||
}));
|
|
||||||
|
setMedicationEnrichment((previous) => {
|
||||||
|
const loadedAdditionalResults = !preserveExistingResults || nextResults.length > previous.results.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
query: data.query,
|
||||||
|
results: nextResults,
|
||||||
|
hasMoreResults: Boolean(data.hasMore) && !reachedResultLimitCap && loadedAdditionalResults,
|
||||||
|
resultLimit: limit,
|
||||||
|
isSearching: false,
|
||||||
|
hasSearched: true,
|
||||||
|
searchError: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
medicationEnrichmentQueryRef.current = data.query;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
|
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
|
||||||
@@ -359,7 +491,7 @@ export function MedicationsPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[medicationEnrichment.query, t]
|
[medicationEnrichment.query, medicationEnrichment.results, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePendingMedicationImageSelection = useCallback(
|
const handlePendingMedicationImageSelection = useCallback(
|
||||||
@@ -407,6 +539,15 @@ export function MedicationsPage() {
|
|||||||
});
|
});
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentSearch = useCallback(async () => {
|
||||||
|
await performMedicationEnrichmentSearch(
|
||||||
|
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||||
|
false,
|
||||||
|
countMedicationEnrichmentDisplayResults(medicationEnrichment.results),
|
||||||
|
medicationEnrichmentQueryRef.current
|
||||||
|
);
|
||||||
|
}, [medicationEnrichment.results, performMedicationEnrichmentSearch]);
|
||||||
|
|
||||||
const loadAllMeds = useCallback(async () => {
|
const loadAllMeds = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
|
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||||
@@ -460,17 +601,27 @@ export function MedicationsPage() {
|
|||||||
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
|
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
|
||||||
if (!allowsPillFormSelection(form.packageType)) return [];
|
if (!allowsPillFormSelection(form.packageType)) return [];
|
||||||
|
|
||||||
return medicationEnrichment.strengthOptions.filter(
|
return [...medicationEnrichment.strengthOptions]
|
||||||
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
.filter(
|
||||||
);
|
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
|
||||||
|
)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftWeight = left.pillWeightMg ?? Number.POSITIVE_INFINITY;
|
||||||
|
const rightWeight = right.pillWeightMg ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (leftWeight !== rightWeight) {
|
||||||
|
return leftWeight - rightWeight;
|
||||||
|
}
|
||||||
|
return left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||||
|
});
|
||||||
}, [form.packageType, medicationEnrichment.strengthOptions]);
|
}, [form.packageType, medicationEnrichment.strengthOptions]);
|
||||||
|
|
||||||
const handleMedicationEnrichmentSearch = useCallback(async () => {
|
|
||||||
await performMedicationEnrichmentSearch(MEDICATION_ENRICHMENT_INITIAL_LIMIT);
|
|
||||||
}, [performMedicationEnrichmentSearch]);
|
|
||||||
|
|
||||||
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
|
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
|
||||||
if (medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults) return;
|
if (
|
||||||
|
medicationEnrichment.isSearching ||
|
||||||
|
!medicationEnrichment.hasMoreResults ||
|
||||||
|
medicationEnrichment.resultLimit >= MEDICATION_ENRICHMENT_MAX_LIMIT
|
||||||
|
)
|
||||||
|
return;
|
||||||
await performMedicationEnrichmentSearch(
|
await performMedicationEnrichmentSearch(
|
||||||
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
|
||||||
true
|
true
|
||||||
@@ -483,16 +634,19 @@ export function MedicationsPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleMedicationEnrichmentApply = useCallback(
|
const handleMedicationEnrichmentApply = useCallback(
|
||||||
async (result: MedicationEnrichmentSearchResult) => {
|
async (result: MedicationEnrichmentSearchResult, preferredPackageOption?: MedicationEnrichmentPackageOption) => {
|
||||||
setMedicationEnrichment((previous) => ({
|
setMedicationEnrichment((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
applyingCode: result.code,
|
applyingCode: result.code,
|
||||||
|
applyingPackageLabel: preferredPackageOption?.label ?? null,
|
||||||
activeResultCode: result.code,
|
activeResultCode: result.code,
|
||||||
enrichError: null,
|
enrichError: null,
|
||||||
appliedSelection: null,
|
appliedSelection: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -510,12 +664,32 @@ export function MedicationsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
|
throw new Error(
|
||||||
|
await getMedicationEnrichmentErrorMessage(
|
||||||
|
response,
|
||||||
|
t("form.enrichment.applyError"),
|
||||||
|
t("form.enrichment.authRequired")
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
|
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
|
||||||
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
|
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
|
||||||
|
let appliedPackageLabel: string | null = null;
|
||||||
let appliedStrengthLabel: string | null = null;
|
let appliedStrengthLabel: string | null = null;
|
||||||
|
const matchedPreferredPackageOption = preferredPackageOption
|
||||||
|
? (data.suggestions.packageOptions.find((option) =>
|
||||||
|
matchesMedicationEnrichmentPackageOption(option, preferredPackageOption)
|
||||||
|
) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (matchedPreferredPackageOption) {
|
||||||
|
nextForm = applyMedicationEnrichmentPackage(nextForm, matchedPreferredPackageOption);
|
||||||
|
appliedPackageLabel = matchedPreferredPackageOption.label;
|
||||||
|
} else if (data.suggestions.packageOptions.length === 1) {
|
||||||
|
nextForm = applyMedicationEnrichmentPackage(nextForm, data.suggestions.packageOptions[0]);
|
||||||
|
appliedPackageLabel = data.suggestions.packageOptions[0].label;
|
||||||
|
}
|
||||||
|
|
||||||
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
|
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
|
||||||
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
|
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
|
||||||
@@ -529,12 +703,15 @@ export function MedicationsPage() {
|
|||||||
setMedicationEnrichment((previous) => ({
|
setMedicationEnrichment((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
activeResultCode: result.code,
|
activeResultCode: result.code,
|
||||||
appliedSelection: data.selection,
|
appliedSelection: data.selection,
|
||||||
enrichError: null,
|
enrichError: null,
|
||||||
meta: data.meta,
|
meta: data.meta,
|
||||||
strengthOptions: data.suggestions.strengthOptions,
|
strengthOptions: data.suggestions.strengthOptions,
|
||||||
|
packageOptions: data.suggestions.packageOptions,
|
||||||
appliedStrengthLabel,
|
appliedStrengthLabel,
|
||||||
|
appliedPackageLabel,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -543,12 +720,15 @@ export function MedicationsPage() {
|
|||||||
setMedicationEnrichment((previous) => ({
|
setMedicationEnrichment((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
activeResultCode: null,
|
activeResultCode: null,
|
||||||
appliedSelection: null,
|
appliedSelection: null,
|
||||||
enrichError: message,
|
enrichError: message,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -569,6 +749,19 @@ export function MedicationsPage() {
|
|||||||
[setForm]
|
[setForm]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleMedicationEnrichmentPackageApply = useCallback(
|
||||||
|
(option: MedicationEnrichmentPackageOption) => {
|
||||||
|
setForm((currentForm) => applyMedicationEnrichmentPackage(currentForm, option));
|
||||||
|
setMedicationEnrichment((previous) => ({
|
||||||
|
...previous,
|
||||||
|
appliedPackageLabel: option.label,
|
||||||
|
applyingPackageLabel: null,
|
||||||
|
appliedStrengthLabel: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setForm]
|
||||||
|
);
|
||||||
|
|
||||||
const medicationEnrichmentViewModel = useMemo(
|
const medicationEnrichmentViewModel = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...medicationEnrichment,
|
...medicationEnrichment,
|
||||||
@@ -1392,23 +1585,35 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="med-total">
|
<div className="med-total">
|
||||||
{t("medications.details.stock")}:{" "}
|
{(() => {
|
||||||
{coverageByMed[getMedDisplayName(med)]
|
const stockDisplayCapacity = getStockDisplayCapacity(med);
|
||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
const currentStock = coverageByMed[getMedDisplayName(med)]
|
||||||
: getPackageSize(med)}{" "}
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
/ {getPackageSize(med)}
|
: getMedTotal(med);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{getMedicationStockSuffix(med)}
|
{getMedicationStockSuffix(med)}
|
||||||
{(coverageByMed[getMedDisplayName(med)]
|
{(() => {
|
||||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
const stockDisplayCapacity = getStockDisplayCapacity(med);
|
||||||
: getPackageSize(med)) > getPackageSize(med) && (
|
const currentStock = coverageByMed[getMedDisplayName(med)]
|
||||||
<span
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
className="info-tooltip tooltip-align-left warning-text"
|
: getMedTotal(med);
|
||||||
data-tooltip={t("tooltips.stockExceedsCapacity")}
|
|
||||||
>
|
return currentStock > stockDisplayCapacity ? (
|
||||||
{" "}
|
<span
|
||||||
⚠️
|
className="info-tooltip tooltip-align-left warning-text"
|
||||||
</span>
|
data-tooltip={t("tooltips.stockExceedsCapacity")}
|
||||||
)}
|
>
|
||||||
|
{" "}
|
||||||
|
⚠️
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1621,6 +1826,7 @@ export function MedicationsPage() {
|
|||||||
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
||||||
onApplyResult={handleMedicationEnrichmentApply}
|
onApplyResult={handleMedicationEnrichmentApply}
|
||||||
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
||||||
|
onApplyPackage={handleMedicationEnrichmentPackageApply}
|
||||||
/>
|
/>
|
||||||
<div className="full date-pair-group">
|
<div className="full date-pair-group">
|
||||||
<label className="date-pair-field">
|
<label className="date-pair-field">
|
||||||
@@ -2286,6 +2492,7 @@ export function MedicationsPage() {
|
|||||||
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
||||||
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
||||||
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
||||||
|
onMedicationEnrichmentPackageApply={handleMedicationEnrichmentPackageApply}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
formSaved={formSaved}
|
formSaved={formSaved}
|
||||||
|
|||||||
+163
-18
@@ -2097,8 +2097,7 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.medication-enrichment-collapsed-hint,
|
.medication-enrichment-collapsed-hint,
|
||||||
.medication-enrichment-description,
|
.medication-enrichment-description,
|
||||||
.medication-enrichment-manual-hint,
|
.medication-enrichment-manual-hint {
|
||||||
.medication-enrichment-selection-summary {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2133,21 +2132,95 @@ button.has-validation-error {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-spinner {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
|
border-top-color: var(--accent-light);
|
||||||
|
animation: medication-enrichment-spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-search-row button {
|
.medication-enrichment-search-row button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-action-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-action-button.is-loading {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--border-primary));
|
||||||
|
background: color-mix(in srgb, var(--accent-bg) 44%, var(--bg-secondary));
|
||||||
|
box-shadow:
|
||||||
|
0 10px 24px rgba(47, 134, 246, 0.16),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-action-button.is-loading:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-results {
|
.medication-enrichment-results {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-toggle-button {
|
||||||
|
min-width: 11rem;
|
||||||
|
justify-self: flex-end;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-toggle-button.primary {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 24px rgba(47, 134, 246, 0.22),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-toggle-button.primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 28px rgba(47, 134, 246, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes medication-enrichment-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-results-footer {
|
.medication-enrichment-results-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-load-more-button.is-loading {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 30%, var(--border-primary));
|
||||||
|
background: color-mix(in srgb, var(--accent-bg) 38%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-result {
|
.medication-enrichment-result {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
@@ -2164,8 +2237,8 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.medication-enrichment-result-header {
|
.medication-enrichment-result-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2187,30 +2260,42 @@ button.has-validation-error {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-package {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-result-actions {
|
.medication-enrichment-result-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medication-enrichment-result-meta {
|
.medication-enrichment-result-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 0.5rem 1rem;
|
gap: 0.9rem 1.15rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medication-enrichment-result-meta div {
|
.medication-enrichment-result-meta div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-result-meta-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.medication-enrichment-result-meta dt {
|
.medication-enrichment-result-meta dt {
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -2222,14 +2307,16 @@ button.has-validation-error {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medication-enrichment-followup {
|
.medication-enrichment-detail-stack {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 0.8rem;
|
||||||
gap: 0.5rem;
|
}
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px dashed var(--border-secondary);
|
.medication-enrichment-package-details {
|
||||||
border-radius: 10px;
|
margin: 0;
|
||||||
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
|
padding-left: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medication-enrichment-strengths {
|
.medication-enrichment-strengths {
|
||||||
@@ -2248,13 +2335,71 @@ button.has-validation-error {
|
|||||||
.medication-enrichment-strength-list {
|
.medication-enrichment-strength-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medication-enrichment-strength-list button {
|
.medication-enrichment-strength-list button {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-detail-hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 44rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-applied-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--success) 35%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--success-bg) 75%, transparent);
|
||||||
|
line-height: 1.45;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-inline-action {
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-package-choice-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-package-choice-list button.medication-enrichment-package-choice-button {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-package-choice-list button.medication-enrichment-package-choice-button.is-loading {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 24px rgba(47, 134, 246, 0.16),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medication-enrichment-pending-panel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border-primary));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--accent-bg) 54%, var(--bg-secondary));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.medication-enrichment-header,
|
.medication-enrichment-header,
|
||||||
.medication-enrichment-result-header,
|
.medication-enrichment-result-header,
|
||||||
|
|||||||
@@ -921,6 +921,39 @@ describe("MedDetailModal stock overflow warning", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MedDetailModal amount-based stock display", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current liquid stock against configured structural capacity", () => {
|
||||||
|
const liquidMed: Medication = {
|
||||||
|
...mockMedication,
|
||||||
|
id: 20,
|
||||||
|
name: "Liquid Multi",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
packCount: 4,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
const liquidCoverage: Coverage = {
|
||||||
|
name: "Liquid Multi",
|
||||||
|
medsLeft: 450,
|
||||||
|
daysLeft: 45,
|
||||||
|
depletionDate: "2024-04-01",
|
||||||
|
depletionTime: Date.now() + 45 * 86400000,
|
||||||
|
nextDose: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} coverage={{ all: [liquidCoverage] }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("450 / 600 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("450 / 450 form.packageAmountUnitMl")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("MedDetailModal bottle package type", () => {
|
describe("MedDetailModal bottle package type", () => {
|
||||||
const bottleMed: Medication = {
|
const bottleMed: Medication = {
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -99,16 +99,20 @@ function createMedicationEnrichmentState(
|
|||||||
return {
|
return {
|
||||||
query: "",
|
query: "",
|
||||||
results: [],
|
results: [],
|
||||||
|
hasMoreResults: false,
|
||||||
isSearching: false,
|
isSearching: false,
|
||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
searchError: null,
|
searchError: null,
|
||||||
applyingCode: null,
|
applyingCode: null,
|
||||||
|
applyingPackageLabel: null,
|
||||||
activeResultCode: null,
|
activeResultCode: null,
|
||||||
appliedSelection: null,
|
appliedSelection: null,
|
||||||
enrichError: null,
|
enrichError: null,
|
||||||
meta: null,
|
meta: null,
|
||||||
strengthOptions: [],
|
strengthOptions: [],
|
||||||
|
packageOptions: [],
|
||||||
appliedStrengthLabel: null,
|
appliedStrengthLabel: null,
|
||||||
|
appliedPackageLabel: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -198,6 +202,7 @@ describe("MobileEditModal", () => {
|
|||||||
const onMedicationEnrichmentSearch = vi.fn();
|
const onMedicationEnrichmentSearch = vi.fn();
|
||||||
const onMedicationEnrichmentApply = vi.fn();
|
const onMedicationEnrichmentApply = vi.fn();
|
||||||
const onMedicationEnrichmentStrengthApply = vi.fn();
|
const onMedicationEnrichmentStrengthApply = vi.fn();
|
||||||
|
const onMedicationEnrichmentPackageApply = vi.fn();
|
||||||
const result = {
|
const result = {
|
||||||
code: "RX-123",
|
code: "RX-123",
|
||||||
name: "Wegovy",
|
name: "Wegovy",
|
||||||
@@ -208,8 +213,21 @@ describe("MobileEditModal", () => {
|
|||||||
genericStatus: "unknown" as const,
|
genericStatus: "unknown" as const,
|
||||||
authorisationDate: null,
|
authorisationDate: null,
|
||||||
source: "rxnorm" as const,
|
source: "rxnorm" as const,
|
||||||
|
packageOptions: [],
|
||||||
};
|
};
|
||||||
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
|
const strengthOption = { label: "0.25 mg", pillWeightMg: 0.25, doseUnit: "mg" as const };
|
||||||
|
const packageOption = {
|
||||||
|
label: "60 tablets in 1 bottle",
|
||||||
|
description: "60 tablets in 1 bottle",
|
||||||
|
packageType: "bottle" as const,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills: 60,
|
||||||
|
looseTablets: 60,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MobileEditModal
|
<MobileEditModal
|
||||||
@@ -217,12 +235,22 @@ describe("MobileEditModal", () => {
|
|||||||
medicationEnrichment={createMedicationEnrichmentState({
|
medicationEnrichment={createMedicationEnrichmentState({
|
||||||
query: "Wegovy",
|
query: "Wegovy",
|
||||||
results: [result],
|
results: [result],
|
||||||
|
appliedSelection: {
|
||||||
|
name: "Wegovy",
|
||||||
|
genericName: "Semaglutide",
|
||||||
|
therapeuticArea: null,
|
||||||
|
indication: null,
|
||||||
|
atcCode: null,
|
||||||
|
source: "rxnorm",
|
||||||
|
},
|
||||||
strengthOptions: [strengthOption],
|
strengthOptions: [strengthOption],
|
||||||
|
packageOptions: [packageOption],
|
||||||
})}
|
})}
|
||||||
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
|
onMedicationEnrichmentQueryChange={onMedicationEnrichmentQueryChange}
|
||||||
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
|
onMedicationEnrichmentSearch={onMedicationEnrichmentSearch}
|
||||||
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||||
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
|
onMedicationEnrichmentStrengthApply={onMedicationEnrichmentStrengthApply}
|
||||||
|
onMedicationEnrichmentPackageApply={onMedicationEnrichmentPackageApply}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,12 +260,76 @@ describe("MobileEditModal", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
fireEvent.keyDown(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), { key: "Enter" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.applyAction" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "0.25 mg" }));
|
|
||||||
|
|
||||||
expect(onMedicationEnrichmentQueryChange).toHaveBeenCalledWith("Ozempic");
|
expect(onMedicationEnrichmentQueryChange).toHaveBeenCalledWith("Ozempic");
|
||||||
expect(onMedicationEnrichmentSearch).toHaveBeenCalledTimes(1);
|
expect(onMedicationEnrichmentSearch).toHaveBeenCalledTimes(1);
|
||||||
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result);
|
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result);
|
||||||
expect(onMedicationEnrichmentStrengthApply).toHaveBeenCalledWith(strengthOption);
|
expect(onMedicationEnrichmentStrengthApply).not.toHaveBeenCalled();
|
||||||
|
expect(onMedicationEnrichmentPackageApply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards inline package option clicks with the preferred package payload in the mobile editor", () => {
|
||||||
|
const onMedicationEnrichmentApply = vi.fn();
|
||||||
|
const packageOptions = [
|
||||||
|
{
|
||||||
|
label: "10 tablets in 1 blister (59651-083-14)",
|
||||||
|
description: "10 tablets in 1 blister (59651-083-14)",
|
||||||
|
packageType: "blister" as const,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "30 tablets in 1 bottle (00093-7424-56)",
|
||||||
|
description: "30 tablets in 1 bottle (00093-7424-56)",
|
||||||
|
packageType: "bottle" as const,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: null,
|
||||||
|
pillsPerBlister: null,
|
||||||
|
totalPills: 30,
|
||||||
|
looseTablets: 30,
|
||||||
|
packageAmountValue: null,
|
||||||
|
packageAmountUnit: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = {
|
||||||
|
code: "NDC-123",
|
||||||
|
name: "Ibuprofen",
|
||||||
|
genericName: "Ibuprofen",
|
||||||
|
authorisationHolder: null,
|
||||||
|
therapeuticArea: null,
|
||||||
|
matchType: "brand" as const,
|
||||||
|
genericStatus: "unknown" as const,
|
||||||
|
authorisationDate: null,
|
||||||
|
source: "openfda" as const,
|
||||||
|
packageOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MobileEditModal
|
||||||
|
{...defaultProps}
|
||||||
|
medicationEnrichment={createMedicationEnrichmentState({
|
||||||
|
query: "Ibuprofen",
|
||||||
|
results: [result],
|
||||||
|
})}
|
||||||
|
onMedicationEnrichmentQueryChange={vi.fn()}
|
||||||
|
onMedicationEnrichmentSearch={vi.fn()}
|
||||||
|
onMedicationEnrichmentApply={onMedicationEnrichmentApply}
|
||||||
|
onMedicationEnrichmentStrengthApply={vi.fn()}
|
||||||
|
onMedicationEnrichmentPackageApply={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.details.showAction" }));
|
||||||
|
const packageButtons = document.querySelectorAll<HTMLButtonElement>(".medication-enrichment-package-choice-button");
|
||||||
|
expect(packageButtons).toHaveLength(2);
|
||||||
|
fireEvent.click(packageButtons[1]);
|
||||||
|
|
||||||
|
expect(onMedicationEnrichmentApply).toHaveBeenCalledWith(result, packageOptions[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups medication start and end date fields in one stacked date pair", () => {
|
it("groups medication start and end date fields in one stacked date pair", () => {
|
||||||
|
|||||||
@@ -113,6 +113,56 @@ describe("ReportModal", () => {
|
|||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports bottle current stock separately from configured capacity", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
1: {
|
||||||
|
dosesTaken: 0,
|
||||||
|
automaticDosesTaken: 0,
|
||||||
|
dosesDismissed: 0,
|
||||||
|
firstDoseAt: null,
|
||||||
|
lastDoseAt: null,
|
||||||
|
refills: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReportModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={onClose}
|
||||||
|
medications={[
|
||||||
|
createMedication({
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
stockAdjustment: 50,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||||
|
const content = await (blob as Blob).text();
|
||||||
|
|
||||||
|
expect(content).toContain("report.docTotalCapacity: 100");
|
||||||
|
expect(content).toContain("report.docCurrentStock: 70 common.pills");
|
||||||
|
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("generates printable report when PDF format is selected", async () => {
|
it("generates printable report when PDF format is selected", async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const mockWrite = vi.fn();
|
const mockWrite = vi.fn();
|
||||||
|
|||||||
@@ -344,6 +344,58 @@ describe("UserFilterModal", () => {
|
|||||||
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows liquid stock against configured multi-container capacity", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onOpenMedDetail = vi.fn();
|
||||||
|
|
||||||
|
const liquidMedication: Medication = {
|
||||||
|
...mockMedication,
|
||||||
|
id: 13,
|
||||||
|
name: "Liquid Multi",
|
||||||
|
genericName: "Liquid Generic",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
packCount: 4,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: 2,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-01-01T09:32:00",
|
||||||
|
intakeUnit: "ml",
|
||||||
|
takenBy: "John",
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const liquidCoverage: Coverage = {
|
||||||
|
name: "Liquid Multi",
|
||||||
|
medsLeft: 450,
|
||||||
|
daysLeft: 30,
|
||||||
|
depletionDate: null,
|
||||||
|
depletionTime: null,
|
||||||
|
nextDose: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<UserFilterModal
|
||||||
|
selectedUser="John"
|
||||||
|
meds={[liquidMedication]}
|
||||||
|
coverage={{ all: [liquidCoverage] }}
|
||||||
|
settings={defaultSettings}
|
||||||
|
onClose={onClose}
|
||||||
|
onClearUser={vi.fn()}
|
||||||
|
onOpenMedDetail={onOpenMedDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("450/600 form.packageAmountUnitMl")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("450/450 form.packageAmountUnitMl")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders liquid container intakes and stock in ml", () => {
|
it("renders liquid container intakes and stock in ml", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onOpenMedDetail = vi.fn();
|
const onOpenMedDetail = vi.fn();
|
||||||
|
|||||||
@@ -89,6 +89,25 @@ describe("useRefill", () => {
|
|||||||
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
|
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resets stale refill form state when opening modal", () => {
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setRefillPacks(4);
|
||||||
|
result.current.setRefillLoose(9);
|
||||||
|
result.current.setUsePrescriptionRefill(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openRefillModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showRefillModal).toBe(true);
|
||||||
|
expect(result.current.refillPacks).toBe(1);
|
||||||
|
expect(result.current.refillLoose).toBe(0);
|
||||||
|
expect(result.current.usePrescriptionRefill).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("closes refill modal using history back", () => {
|
it("closes refill modal using history back", () => {
|
||||||
const { result } = renderHook(() => useRefill());
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
@@ -325,42 +344,197 @@ describe("useRefill", () => {
|
|||||||
expect(mockLoadMeds).toHaveBeenCalled();
|
expect(mockLoadMeds).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stock correction uses correct base for bottle type medications", async () => {
|
it("resets blister stock correction payload to zero base fields", async () => {
|
||||||
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
|
|
||||||
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
|
|
||||||
// This mismatch caused the correction to compute the wrong stockAdjustment.
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
const bottleMed: Medication = {
|
const blisterMed: Medication = {
|
||||||
id: 4,
|
id: 8,
|
||||||
name: "Pills in a Box",
|
name: "Zero Reset Blister",
|
||||||
packageType: "bottle",
|
packageType: "blister",
|
||||||
packCount: 1,
|
packCount: 2,
|
||||||
blistersPerPack: 1,
|
blistersPerPack: 3,
|
||||||
pillsPerBlister: 1,
|
pillsPerBlister: 10,
|
||||||
looseTablets: 150,
|
looseTablets: 5,
|
||||||
stockAdjustment: -2,
|
stockAdjustment: -4,
|
||||||
takenBy: [],
|
takenBy: [],
|
||||||
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
|
const mockLoadMeds = vi.fn();
|
||||||
// getPackageSize for bottle = looseTablets = 150
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(blisterMed, {
|
||||||
|
all: [{ name: "Zero Reset Blister", medsLeft: 31, daysLeft: 31 }] as Coverage[],
|
||||||
|
});
|
||||||
|
result.current.setEditStockFullBlisters(0);
|
||||||
|
result.current.setEditStockPartialBlisterPills(0);
|
||||||
|
result.current.setEditStockLoosePills(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = JSON.parse(requestInit.body as string);
|
||||||
|
expect(body).toEqual({
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 0,
|
||||||
|
looseTablets: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets bottle stock correction payload to zero base fields", async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const bottleMed: Medication = {
|
||||||
|
id: 9,
|
||||||
|
name: "Zero Reset Bottle",
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
stockAdjustment: 5,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
const mockLoadMeds = vi.fn();
|
const mockLoadMeds = vi.fn();
|
||||||
const { result } = renderHook(() => useRefill());
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
// Pre-fill for bottle: full=0, partial=current total
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.openEditStockModal(bottleMed, {
|
result.current.openEditStockModal(bottleMed, {
|
||||||
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
|
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||||
|
});
|
||||||
|
result.current.setEditStockFullBlisters(0);
|
||||||
|
result.current.setEditStockPartialBlisterPills(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = JSON.parse(requestInit.body as string);
|
||||||
|
expect(body).toEqual({
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 0,
|
||||||
|
looseTablets: 0,
|
||||||
|
totalPills: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: "liquid container",
|
||||||
|
id: 10,
|
||||||
|
med: {
|
||||||
|
id: 10,
|
||||||
|
name: "Zero Reset Liquid",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 180,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 180,
|
||||||
|
looseTablets: 180,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
} satisfies Medication,
|
||||||
|
coverage: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "tube",
|
||||||
|
id: 11,
|
||||||
|
med: {
|
||||||
|
id: 11,
|
||||||
|
name: "Zero Reset Tube",
|
||||||
|
medicationForm: "topical",
|
||||||
|
packageType: "tube",
|
||||||
|
doseUnit: "units",
|
||||||
|
packCount: 2,
|
||||||
|
packageAmountValue: 40,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 80,
|
||||||
|
looseTablets: 80,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 2, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
} satisfies Medication,
|
||||||
|
coverage: 80,
|
||||||
|
},
|
||||||
|
])("resets $label stock correction payload to zero amount-base fields", async ({ id, med, coverage }) => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const mockLoadMeds = vi.fn();
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(med, {
|
||||||
|
all: [{ name: med.name, medsLeft: coverage, daysLeft: coverage }] as Coverage[],
|
||||||
|
});
|
||||||
|
result.current.setEditStockFullBlisters(0);
|
||||||
|
result.current.setEditStockPartialBlisterPills(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(id, med, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = JSON.parse(requestInit.body as string);
|
||||||
|
expect(body).toEqual({
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 0,
|
||||||
|
looseTablets: 0,
|
||||||
|
totalPills: 0,
|
||||||
|
packageAmountValue: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const bottleMed: Medication = {
|
||||||
|
id: 4,
|
||||||
|
name: "Capacity Bottle",
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
stockAdjustment: 5,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoadMeds = vi.fn();
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(bottleMed, {
|
||||||
|
all: [{ name: "Capacity Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// User sets total to 149 pills.
|
// User corrects current stock to 70 pills.
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setEditStockPartialBlisterPills(149);
|
result.current.setEditStockPartialBlisterPills(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -376,7 +550,8 @@ describe("useRefill", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchCall).toBeDefined();
|
expect(fetchCall).toBeDefined();
|
||||||
const body = JSON.parse(fetchCall![1].body as string);
|
const body = JSON.parse(fetchCall![1].body as string);
|
||||||
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
|
expect(body.stockAdjustment).toBe(50);
|
||||||
|
expect(body.looseTablets).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stock correction clamps blister totals to package size", async () => {
|
it("stock correction clamps blister totals to package size", async () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
import { FIELD_LIMITS, getMedTotal, getPackageSize, getStockDisplayCapacity } from "../types";
|
||||||
|
|
||||||
describe("getMedTotal", () => {
|
describe("getMedTotal", () => {
|
||||||
it("calculates total pills without stock adjustment", () => {
|
it("calculates total pills without stock adjustment", () => {
|
||||||
@@ -85,6 +85,20 @@ describe("getMedTotal", () => {
|
|||||||
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
|
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses loose stock for bottle current total even when explicit capacity exists", () => {
|
||||||
|
const med = {
|
||||||
|
packageType: "bottle" as const,
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 20,
|
||||||
|
stockAdjustment: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getMedTotal(med)).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores blister fields for bottle type", () => {
|
it("ignores blister fields for bottle type", () => {
|
||||||
const med = {
|
const med = {
|
||||||
packageType: "bottle" as const,
|
packageType: "bottle" as const,
|
||||||
@@ -158,6 +172,20 @@ describe("getPackageSize", () => {
|
|||||||
expect(getPackageSize(med)).toBe(200);
|
expect(getPackageSize(med)).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns explicit bottle capacity instead of current stock", () => {
|
||||||
|
const med = {
|
||||||
|
packageType: "bottle" as const,
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 70,
|
||||||
|
stockAdjustment: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPackageSize(med)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores blister fields for bottle type", () => {
|
it("ignores blister fields for bottle type", () => {
|
||||||
const med = {
|
const med = {
|
||||||
packageType: "bottle" as const,
|
packageType: "bottle" as const,
|
||||||
@@ -195,6 +223,62 @@ describe("getPackageSize", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStockDisplayCapacity", () => {
|
||||||
|
it("returns configured multi-container capacity for liquid containers", () => {
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStockDisplayCapacity(liquid)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns configured multi-container capacity for tubes", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStockDisplayCapacity(tube)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to current package size when amount metadata is missing", () => {
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 4,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 450,
|
||||||
|
looseTablets: 450,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStockDisplayCapacity(liquid)).toBe(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps bottle semantics unchanged", () => {
|
||||||
|
const bottle = {
|
||||||
|
packageType: "bottle" as const,
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getStockDisplayCapacity(bottle)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FIELD_LIMITS", () => {
|
describe("FIELD_LIMITS", () => {
|
||||||
it("has correct limits for name field", () => {
|
it("has correct limits for name field", () => {
|
||||||
expect(FIELD_LIMITS.name.min).toBe(0);
|
expect(FIELD_LIMITS.name.min).toBe(0);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export {
|
|||||||
} from "./package-profiles";
|
} from "./package-profiles";
|
||||||
|
|
||||||
import type { PackageType } from "./package-profiles";
|
import type { PackageType } from "./package-profiles";
|
||||||
import { isAmountBasedPackageType } from "./package-profiles";
|
import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "./package-profiles";
|
||||||
|
|
||||||
// Common medication dose units
|
// Common medication dose units
|
||||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||||
@@ -47,6 +47,7 @@ export type MedicationEnrichmentSearchResult = {
|
|||||||
genericStatus: MedicationEnrichmentGenericStatus;
|
genericStatus: MedicationEnrichmentGenericStatus;
|
||||||
authorisationDate: string | null;
|
authorisationDate: string | null;
|
||||||
source: MedicationEnrichmentSearchSource;
|
source: MedicationEnrichmentSearchSource;
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationEnrichmentSearchResponse = {
|
export type MedicationEnrichmentSearchResponse = {
|
||||||
@@ -62,6 +63,19 @@ export type MedicationEnrichmentStrengthOption = {
|
|||||||
doseUnit: MedicationEnrichmentDoseUnit | null;
|
doseUnit: MedicationEnrichmentDoseUnit | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationEnrichmentPackageOption = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
packageType: PackageType;
|
||||||
|
packCount: number;
|
||||||
|
blistersPerPack: number | null;
|
||||||
|
pillsPerBlister: number | null;
|
||||||
|
totalPills: number | null;
|
||||||
|
looseTablets: number | null;
|
||||||
|
packageAmountValue: number | null;
|
||||||
|
packageAmountUnit: PackageAmountUnit | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationEnrichmentEnrichResponse = {
|
export type MedicationEnrichmentEnrichResponse = {
|
||||||
selection: {
|
selection: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -76,6 +90,7 @@ export type MedicationEnrichmentEnrichResponse = {
|
|||||||
genericName: string | null;
|
genericName: string | null;
|
||||||
medicationForm: MedicationForm | null;
|
medicationForm: MedicationForm | null;
|
||||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||||
|
packageOptions: MedicationEnrichmentPackageOption[];
|
||||||
};
|
};
|
||||||
meta: {
|
meta: {
|
||||||
rxNormMatched: boolean;
|
rxNormMatched: boolean;
|
||||||
@@ -379,7 +394,10 @@ export function getMedDisplayName(med: { name: string; genericName?: string | nu
|
|||||||
// Helper Functions for Medication Calculations
|
// Helper Functions for Medication Calculations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
|
type MedLike = Pick<
|
||||||
|
Medication,
|
||||||
|
"packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets" | "packageAmountValue"
|
||||||
|
> & {
|
||||||
stockAdjustment?: number;
|
stockAdjustment?: number;
|
||||||
packageType?: PackageType;
|
packageType?: PackageType;
|
||||||
totalPills?: number | null;
|
totalPills?: number | null;
|
||||||
@@ -387,6 +405,10 @@ type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlist
|
|||||||
|
|
||||||
/** Calculate total pills including stockAdjustment */
|
/** Calculate total pills including stockAdjustment */
|
||||||
export function getMedTotal(med: MedLike): number {
|
export function getMedTotal(med: MedLike): number {
|
||||||
|
if (med.packageType === "bottle") {
|
||||||
|
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Amount-based package types store their current base stock directly
|
// 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 (isAmountBasedPackageType(med.packageType)) {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
@@ -399,6 +421,10 @@ 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 {
|
||||||
|
if (med.packageType === "bottle") {
|
||||||
|
return med.totalPills ?? med.looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
// Amount-based package types use totalPills as base capacity
|
// Amount-based package types use totalPills as base capacity
|
||||||
if (isAmountBasedPackageType(med.packageType)) {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
return med.totalPills ?? med.looseTablets;
|
return med.totalPills ?? med.looseTablets;
|
||||||
@@ -406,3 +432,16 @@ export function getPackageSize(med: MedLike): number {
|
|||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the configured structural capacity used for stock display/limits. */
|
||||||
|
export function getStockDisplayCapacity(med: MedLike): number {
|
||||||
|
if (isLiquidContainerPackageType(med.packageType) || isTubePackageType(med.packageType)) {
|
||||||
|
const packageCount = Math.max(1, med.packCount || 1);
|
||||||
|
const packageAmountValue = Number(med.packageAmountValue ?? 0);
|
||||||
|
if (Number.isFinite(packageAmountValue) && packageAmountValue > 0) {
|
||||||
|
return packageCount * packageAmountValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPackageSize(med);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export * from "./formatters";
|
export * from "./formatters";
|
||||||
export * from "./ics";
|
export * from "./ics";
|
||||||
|
export * from "./medication-enrichment";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
export * from "./stock";
|
export * from "./stock";
|
||||||
export * from "./storage";
|
export * from "./storage";
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MedicationEnrichmentSearchResult } from "../types";
|
||||||
|
|
||||||
|
function normalizeMedicationEnrichmentGroupingText(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim().toUpperCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMedicationEnrichmentDisplayResultKey(result: MedicationEnrichmentSearchResult): string {
|
||||||
|
if (result.source === "openfda") {
|
||||||
|
return `openfda:${normalizeMedicationEnrichmentGroupingText(result.name)}:${normalizeMedicationEnrichmentGroupingText(result.genericName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countMedicationEnrichmentDisplayResults(results: MedicationEnrichmentSearchResult[]): number {
|
||||||
|
return new Set(results.map(getMedicationEnrichmentDisplayResultKey)).size;
|
||||||
|
}
|
||||||
Generated
+36
-36
@@ -6,7 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng",
|
"name": "medassist-ng",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.4.0"
|
"lint-staged": "^16.4.0"
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
|
||||||
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
|
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -92,20 +92,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.4.7",
|
"@biomejs/cli-darwin-arm64": "2.4.8",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.7",
|
"@biomejs/cli-darwin-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.7",
|
"@biomejs/cli-linux-arm64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.7",
|
"@biomejs/cli-linux-arm64-musl": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64": "2.4.7",
|
"@biomejs/cli-linux-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.7",
|
"@biomejs/cli-linux-x64-musl": "2.4.8",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.7",
|
"@biomejs/cli-win32-arm64": "2.4.8",
|
||||||
"@biomejs/cli-win32-x64": "2.4.7"
|
"@biomejs/cli-win32-x64": "2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
|
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -120,9 +120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
|
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -137,9 +137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
|
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -154,9 +154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
|
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -171,9 +171,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
|
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -188,9 +188,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
|
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -205,9 +205,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
|
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -222,9 +222,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.4.7",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
|
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.8",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.4.0"
|
"lint-staged": "^16.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user