mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
Add 11 major features for caregiver health management
Features added: - Emergency Info Card: Full-screen emergency view with patient info - Refill Tracker: Track pill counts with auto-decrement on dose - Activity Feed: View caregiver activity with filtering - Symptom Tracker: Log symptoms with severity and offline sync - Print Views: Daily meds, appointments, doctor visit summaries - iCal Export: Calendar subscription for appointments - PDF Export: Medical summary for doctor visits - Calendar View: Monthly calendar for appointments - Appointment Preparation: Checklist for upcoming appointments - Medication Reminders: PWA push notifications with quiet hours Bug fixes: - Fix invite workflow: Register/login now properly redirect back - Add undo for doctor questions (can unmark "asked" questions) - Fix API route type annotations for Next.js 14 compatibility - Add Suspense boundary for useSearchParams in login/register Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,10 +45,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
# Do not expose PostgreSQL to the host - only accessible within the network
|
||||
# If you need direct access for migrations, uncomment below:
|
||||
# ports:
|
||||
# - "127.0.0.1:5432:5432"
|
||||
# Expose PostgreSQL to localhost for migrations
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
7
firebase-debug.log
Normal file
7
firebase-debug.log
Normal file
@@ -0,0 +1,7 @@
|
||||
[debug] [2026-01-19T13:24:37.682Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.718Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.720Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.965Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.967Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.969Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
[debug] [2026-01-19T13:24:37.970Z] > command requires scopes: ["email","openid","https://www.googleapis.com/auth/cloudplatformprojects.readonly","https://www.googleapis.com/auth/firebase","https://www.googleapis.com/auth/cloud-platform"]
|
||||
323
package-lock.json
generated
323
package-lock.json
generated
@@ -19,16 +19,20 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "^14.2.21",
|
||||
"pdfkit": "^0.17.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.21",
|
||||
@@ -1456,6 +1460,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
|
||||
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1482,6 +1496,16 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
|
||||
@@ -2163,6 +2187,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -2426,6 +2459,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2533,6 +2578,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
|
||||
@@ -2556,6 +2621,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -2580,6 +2651,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -2614,6 +2694,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@@ -2813,6 +2899,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2874,6 +2969,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2977,7 +3078,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3061,6 +3161,12 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -3110,6 +3216,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
@@ -3845,7 +3960,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -3967,6 +4081,32 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -4381,6 +4521,28 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4434,7 +4596,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
@@ -4948,6 +5109,13 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5017,6 +5185,27 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5074,6 +5263,25 @@
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -5193,6 +5401,12 @@
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -5210,7 +5424,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5230,7 +5443,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@@ -5630,6 +5842,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -5714,6 +5932,19 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5753,6 +5984,11 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -6175,6 +6411,12 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -6314,6 +6556,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -6349,6 +6611,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -6964,6 +7232,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -7272,6 +7546,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
@@ -7934,6 +8228,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -28,16 +28,20 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "^14.2.21",
|
||||
"pdfkit": "^0.17.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.21",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SymptomType" AS ENUM ('FATIGUE', 'NAUSEA', 'PAIN', 'APPETITE', 'SLEEP', 'MOOD', 'CUSTOM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Medication" ADD COLUMN "lastRefillDate" TIMESTAMP(3),
|
||||
ADD COLUMN "pillCount" INTEGER,
|
||||
ADD COLUMN "pillsPerDose" INTEGER DEFAULT 1,
|
||||
ADD COLUMN "refillThreshold" INTEGER DEFAULT 7;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Workspace" ADD COLUMN "allergies" TEXT,
|
||||
ADD COLUMN "bloodType" TEXT,
|
||||
ADD COLUMN "medicalConditions" TEXT,
|
||||
ADD COLUMN "patientDOB" TIMESTAMP(3),
|
||||
ADD COLUMN "patientName" TEXT,
|
||||
ADD COLUMN "physicianPhone" TEXT,
|
||||
ADD COLUMN "primaryPhysician" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Symptom" (
|
||||
"id" TEXT NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"type" "SymptomType" NOT NULL,
|
||||
"customName" TEXT,
|
||||
"severity" INTEGER NOT NULL,
|
||||
"notes" TEXT,
|
||||
"recordedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"createdById" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Symptom_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppointmentChecklist" (
|
||||
"id" TEXT NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"appointmentId" TEXT NOT NULL,
|
||||
"item" TEXT NOT NULL,
|
||||
"isReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AppointmentChecklist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Symptom_workspaceId_recordedAt_idx" ON "Symptom"("workspaceId", "recordedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Symptom_workspaceId_type_idx" ON "Symptom"("workspaceId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Symptom_workspaceId_deletedAt_idx" ON "Symptom"("workspaceId", "deletedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Symptom_syncedAt_idx" ON "Symptom"("syncedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AppointmentChecklist_appointmentId_idx" ON "AppointmentChecklist"("appointmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AppointmentChecklist_workspaceId_idx" ON "AppointmentChecklist"("workspaceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_workspaceId_idx" ON "PushSubscription"("workspaceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_userId_endpoint_key" ON "PushSubscription"("userId", "endpoint");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Symptom" ADD CONSTRAINT "Symptom_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Symptom" ADD CONSTRAINT "Symptom_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AppointmentChecklist" ADD CONSTRAINT "AppointmentChecklist_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AppointmentChecklist" ADD CONSTRAINT "AppointmentChecklist_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -31,6 +31,8 @@ model User {
|
||||
loggedDoses DoseLog[] @relation("DoseLoggedBy")
|
||||
undoneDoses DoseLog[] @relation("DoseUndoneBy")
|
||||
auditLogs AuditLog[]
|
||||
symptoms Symptom[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -78,6 +80,15 @@ model Workspace {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Emergency info fields
|
||||
patientName String?
|
||||
patientDOB DateTime?
|
||||
bloodType String?
|
||||
allergies String? // Comma-separated or free text
|
||||
medicalConditions String? // Comma-separated or free text
|
||||
primaryPhysician String?
|
||||
physicianPhone String?
|
||||
|
||||
// Relations
|
||||
members WorkspaceMember[]
|
||||
inviteTokens InviteToken[]
|
||||
@@ -87,6 +98,9 @@ model Workspace {
|
||||
doseLogs DoseLog[]
|
||||
auditLogs AuditLog[]
|
||||
syncCursors SyncCursor[]
|
||||
symptoms Symptom[]
|
||||
appointmentChecklists AppointmentChecklist[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
@@ -155,6 +169,7 @@ model Appointment {
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("AppointmentCreatedBy", fields: [createdById], references: [id])
|
||||
updatedBy User @relation("AppointmentUpdatedBy", fields: [updatedById], references: [id])
|
||||
checklists AppointmentChecklist[]
|
||||
|
||||
@@index([workspaceId, datetime])
|
||||
@@index([workspaceId, deletedAt])
|
||||
@@ -188,6 +203,12 @@ model Medication {
|
||||
createdById String
|
||||
updatedById String
|
||||
|
||||
// Refill tracking fields
|
||||
pillCount Int?
|
||||
pillsPerDose Int? @default(1)
|
||||
refillThreshold Int? @default(7)
|
||||
lastRefillDate DateTime?
|
||||
|
||||
// Sync tracking
|
||||
version Int @default(1)
|
||||
syncedAt DateTime @default(now())
|
||||
@@ -290,6 +311,88 @@ model AuditLog {
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYMPTOMS
|
||||
// ============================================
|
||||
|
||||
enum SymptomType {
|
||||
FATIGUE
|
||||
NAUSEA
|
||||
PAIN
|
||||
APPETITE
|
||||
SLEEP
|
||||
MOOD
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
model Symptom {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
type SymptomType
|
||||
customName String? // Only used when type is CUSTOM
|
||||
severity Int // 1-5 scale
|
||||
notes String?
|
||||
recordedAt DateTime @default(now())
|
||||
deletedAt DateTime?
|
||||
createdById String
|
||||
|
||||
// Sync tracking
|
||||
version Int @default(1)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
|
||||
@@index([workspaceId, recordedAt])
|
||||
@@index([workspaceId, type])
|
||||
@@index([workspaceId, deletedAt])
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPOINTMENT CHECKLIST
|
||||
// ============================================
|
||||
|
||||
model AppointmentChecklist {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
appointmentId String
|
||||
item String
|
||||
isReady Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, appointmentId, item])
|
||||
@@index([appointmentId])
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PUSH NOTIFICATIONS
|
||||
// ============================================
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
workspaceId String
|
||||
endpoint String
|
||||
p256dh String
|
||||
auth String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, endpoint])
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYNC
|
||||
// ============================================
|
||||
|
||||
148
public/sw.js
Normal file
148
public/sw.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// NextStep Service Worker for Push Notifications
|
||||
|
||||
const CACHE_NAME = 'nextstep-v1'
|
||||
|
||||
// Install event - cache critical assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker: Installing...')
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll([
|
||||
'/',
|
||||
'/today',
|
||||
'/meds',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
])
|
||||
})
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker: Activating...')
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
})
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// Fetch event - serve from cache when offline
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only cache GET requests
|
||||
if (event.request.method !== 'GET') return
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
// Return cached version or fetch from network
|
||||
return (
|
||||
cached ||
|
||||
fetch(event.request).then((response) => {
|
||||
// Don't cache API responses
|
||||
if (event.request.url.includes('/api/')) {
|
||||
return response
|
||||
}
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, clone)
|
||||
})
|
||||
}
|
||||
return response
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Push event - handle incoming push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Service Worker: Push received')
|
||||
|
||||
let data = {
|
||||
title: 'Medication Reminder',
|
||||
body: 'Time to take your medication',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
tag: 'medication-reminder',
|
||||
data: {
|
||||
url: '/meds',
|
||||
},
|
||||
}
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
data = { ...data, ...event.data.json() }
|
||||
} catch (e) {
|
||||
console.error('Failed to parse push data:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon || '/icon-192.png',
|
||||
badge: data.badge || '/badge-72.png',
|
||||
tag: data.tag || 'default',
|
||||
vibrate: [100, 50, 100],
|
||||
data: data.data || {},
|
||||
actions: data.actions || [
|
||||
{ action: 'take', title: 'Taken' },
|
||||
{ action: 'snooze', title: 'Snooze' },
|
||||
],
|
||||
requireInteraction: true,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options))
|
||||
})
|
||||
|
||||
// Notification click event - handle user interaction
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Service Worker: Notification clicked', event.action)
|
||||
|
||||
event.notification.close()
|
||||
|
||||
const data = event.notification.data || {}
|
||||
let url = data.url || '/meds'
|
||||
|
||||
// Handle different actions
|
||||
if (event.action === 'take' && data.medicationId) {
|
||||
// Open meds page with action to log dose
|
||||
url = `/meds?action=take&id=${data.medicationId}`
|
||||
} else if (event.action === 'snooze') {
|
||||
// Schedule a new notification in 15 minutes
|
||||
// This would require server-side logic
|
||||
console.log('Snooze requested')
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Try to focus an existing window
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes('/') && 'focus' in client) {
|
||||
client.navigate(url)
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
// Open new window if none exists
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Background sync event - for offline dose logging
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-doses') {
|
||||
console.log('Service Worker: Syncing doses...')
|
||||
// This would sync offline dose logs when connection is restored
|
||||
}
|
||||
})
|
||||
133
src/app/(app)/activity/page.tsx
Normal file
133
src/app/(app)/activity/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { Card, LoadingState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { ActivityItem } from '@/components/activity/ActivityItem'
|
||||
import { ActivityFilter } from '@/components/activity/ActivityFilter'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
details: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
user: { id: string; name: string }
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
const [activities, setActivities] = useState<Activity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [entityType, setEntityType] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const fetchActivities = useCallback(async (reset = false) => {
|
||||
const currentOffset = reset ? 0 : offset
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: String(currentOffset),
|
||||
...(entityType && { entityType }),
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/activity?${params}`
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (reset) {
|
||||
setActivities(data.activities)
|
||||
setOffset(data.activities.length)
|
||||
} else {
|
||||
setActivities((prev) => [...prev, ...data.activities])
|
||||
setOffset(currentOffset + data.activities.length)
|
||||
}
|
||||
setHasMore(data.hasMore)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch activities:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [currentWorkspace.id, entityType, offset])
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivities(true)
|
||||
}, [currentWorkspace.id, entityType])
|
||||
|
||||
const handleLoadMore = () => {
|
||||
fetchActivities(false)
|
||||
}
|
||||
|
||||
const handleEntityTypeChange = (type: string) => {
|
||||
setEntityType(type)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Activity" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading activity..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Activity" showBack />
|
||||
<PageContainer className="pt-4 space-y-4">
|
||||
<ActivityFilter
|
||||
entityType={entityType}
|
||||
onEntityTypeChange={handleEntityTypeChange}
|
||||
/>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<Card variant="outline" className="text-center py-8">
|
||||
<p className="text-secondary-500">No activity yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
<div className="divide-y divide-border">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="px-4">
|
||||
<ActivityItem activity={activity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pb-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleLoadMore}
|
||||
loading={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
262
src/app/(app)/appointments/[id]/page.tsx
Normal file
262
src/app/(app)/appointments/[id]/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { format, parseISO, isPast, isTomorrow, isToday } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import {
|
||||
Calendar,
|
||||
MapPin,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
ClipboardCheck,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Card, Button, LoadingState, Modal, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
mapUrl: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export default function AppointmentDetailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const appointmentId = params.id as string
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [appointment, setAppointment] = useState<Appointment | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAppointment() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAppointment(data.appointment)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch appointment:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAppointment()
|
||||
}, [currentWorkspace.id, appointmentId])
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
|
||||
showToast('Appointment deleted', 'success')
|
||||
refreshData()
|
||||
router.push('/appointments')
|
||||
} catch {
|
||||
showToast('Failed to delete', 'error')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setShowDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Appointment" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!appointment) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Appointment" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-secondary-500">Appointment not found</p>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const apptDate = toZonedTime(parseISO(appointment.datetime), TIMEZONE)
|
||||
const isInPast = isPast(apptDate)
|
||||
const showPrepButton = !isInPast && (isToday(apptDate) || isTomorrow(apptDate) || !isPast(apptDate))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Appointment"
|
||||
showBack
|
||||
rightAction={{
|
||||
icon: <Edit className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Edit',
|
||||
onClick: () => router.push(`/appointments/${appointmentId}/edit`),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-4">
|
||||
{/* Main details */}
|
||||
<Card>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isInPast ? 'bg-secondary-100' : 'bg-primary-100'
|
||||
}`}
|
||||
>
|
||||
<Calendar
|
||||
className={`w-7 h-7 ${
|
||||
isInPast ? 'text-secondary-400' : 'text-primary-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-secondary-900">
|
||||
{appointment.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<p
|
||||
className={`flex items-center gap-2 ${
|
||||
isInPast ? 'text-secondary-400' : 'text-secondary-700'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-5 h-5" />
|
||||
<span>
|
||||
{format(apptDate, 'EEEE, MMMM d, yyyy')} at{' '}
|
||||
{format(apptDate, 'h:mm a')}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{appointment.location && (
|
||||
<div className="flex items-start gap-2 text-secondary-600">
|
||||
<MapPin className="w-5 h-5 mt-0.5" />
|
||||
<span>{appointment.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Map link */}
|
||||
{appointment.mapUrl && (
|
||||
<a
|
||||
href={appointment.mapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Card className="hover:bg-secondary-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-primary-600" />
|
||||
<span className="font-medium text-primary-600">Open in Maps</span>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Preparation checklist */}
|
||||
{showPrepButton && (
|
||||
<Card
|
||||
onClick={() => router.push(`/appointments/${appointmentId}/prep`)}
|
||||
className="hover:bg-secondary-50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<ClipboardCheck className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-secondary-900">
|
||||
Prepare for this appointment
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Checklist to help you get ready
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{appointment.notes && (
|
||||
<Card>
|
||||
<h2 className="font-semibold text-secondary-700 mb-2">Notes</h2>
|
||||
<p className="text-secondary-600 whitespace-pre-wrap">
|
||||
{appointment.notes}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:bg-red-50 w-full"
|
||||
onClick={() => setShowDelete(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Appointment
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Modal
|
||||
isOpen={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
title="Delete Appointment"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Are you sure you want to delete "{appointment.title}"? This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
loading={deleting}
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
142
src/app/(app)/appointments/[id]/prep/page.tsx
Normal file
142
src/app/(app)/appointments/[id]/prep/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { Calendar, MapPin, Clock, Printer } from 'lucide-react'
|
||||
|
||||
import { Card, LoadingState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { PrepChecklist } from '@/components/appointments/PrepChecklist'
|
||||
import { useApp } from '../../../provider'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export default function AppointmentPrepPage() {
|
||||
const params = useParams()
|
||||
const appointmentId = params.id as string
|
||||
const { currentWorkspace } = useApp()
|
||||
const [appointment, setAppointment] = useState<Appointment | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAppointment() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments/${appointmentId}`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAppointment(data.appointment)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch appointment:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAppointment()
|
||||
}, [currentWorkspace.id, appointmentId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Prepare" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!appointment) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Prepare" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-secondary-500">Appointment not found</p>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const apptDate = parseISO(appointment.datetime)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Prepare for Appointment"
|
||||
showBack
|
||||
rightAction={{
|
||||
icon: <Printer className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Print',
|
||||
onClick: () => window.print(),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Appointment summary */}
|
||||
<Card className="bg-primary-50 border border-primary-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Calendar className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg text-secondary-900">
|
||||
{appointment.title}
|
||||
</h2>
|
||||
<p className="text-primary-700 flex items-center gap-1 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{format(apptDate, 'EEEE, MMMM d')} at {format(apptDate, 'h:mm a')}
|
||||
</p>
|
||||
{appointment.location && (
|
||||
<p className="text-secondary-600 flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{appointment.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-blue-900 mb-2">Preparation Tips</h3>
|
||||
<ul className="text-sm text-blue-800 list-disc list-inside space-y-1">
|
||||
<li>Arrive 15 minutes early</li>
|
||||
<li>Bring all items on your checklist</li>
|
||||
<li>Have someone drive you if needed</li>
|
||||
<li>Eat a light meal beforehand unless fasting</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 mb-4">
|
||||
Preparation Checklist
|
||||
</h3>
|
||||
<PrepChecklist
|
||||
workspaceId={currentWorkspace.id}
|
||||
appointmentId={appointmentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{appointment.notes && (
|
||||
<Card>
|
||||
<h3 className="font-medium text-secondary-700 mb-2">Notes</h3>
|
||||
<p className="text-secondary-600 whitespace-pre-wrap">{appointment.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
115
src/app/(app)/appointments/calendar/page.tsx
Normal file
115
src/app/(app)/appointments/calendar/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { List, Plus } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { Card, LoadingState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { CalendarMonth } from '@/components/calendar/CalendarMonth'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [serverAppointments, setServerAppointments] = useState<any[]>([])
|
||||
|
||||
// Fetch from IndexedDB for offline support
|
||||
const localAppointments = useLiveQuery(
|
||||
() =>
|
||||
db.appointments
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((a) => !a.deletedAt)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Also fetch from server for latest data
|
||||
useEffect(() => {
|
||||
async function fetchAppointments() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setServerAppointments(data.appointments)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch appointments:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAppointments()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
// Prefer server data if available
|
||||
const appointments = serverAppointments.length > 0 ? serverAppointments : localAppointments || []
|
||||
|
||||
if (loading && !localAppointments) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Calendar"
|
||||
showBack
|
||||
rightAction={{
|
||||
icon: <List className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'List view',
|
||||
onClick: () => router.push('/appointments'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading appointments..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Calendar"
|
||||
showBack
|
||||
rightAction={{
|
||||
icon: <List className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'List view',
|
||||
onClick: () => router.push('/appointments'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-4">
|
||||
<Card>
|
||||
<CalendarMonth
|
||||
appointments={appointments.map((a: any) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
datetime: a.datetime,
|
||||
location: a.location,
|
||||
}))}
|
||||
selectedDate={selectedDate}
|
||||
onDateSelect={setSelectedDate}
|
||||
onMonthChange={setCurrentMonth}
|
||||
currentMonth={currentMonth}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Add appointment FAB */}
|
||||
<div className="fixed bottom-20 right-4 z-30">
|
||||
<Button
|
||||
onClick={() => router.push('/appointments/new')}
|
||||
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Plus, Calendar, MapPin, Clock, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Calendar, MapPin, Clock, ChevronRight, CalendarDays } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { Card, LoadingState, EmptyState } from '@/components/ui'
|
||||
import { Card, LoadingState, EmptyState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
@@ -59,9 +59,9 @@ export default function AppointmentsPage() {
|
||||
<Header
|
||||
title="Appointments"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add appointment',
|
||||
onClick: () => router.push('/appointments/new'),
|
||||
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Calendar view',
|
||||
onClick: () => router.push('/appointments/calendar'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer>
|
||||
@@ -76,9 +76,9 @@ export default function AppointmentsPage() {
|
||||
<Header
|
||||
title="Appointments"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add appointment',
|
||||
onClick: () => router.push('/appointments/new'),
|
||||
icon: <CalendarDays className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Calendar view',
|
||||
onClick: () => router.push('/appointments/calendar'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
@@ -158,6 +158,16 @@ export default function AppointmentsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add appointment FAB */}
|
||||
<div className="fixed bottom-20 right-4 z-30">
|
||||
<Button
|
||||
onClick={() => router.push('/appointments/new')}
|
||||
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
|
||||
115
src/app/(app)/emergency/page.tsx
Normal file
115
src/app/(app)/emergency/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Edit2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { EmergencyCard } from '@/components/emergency/EmergencyCard'
|
||||
import { Button, LoadingState } from '@/components/ui'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function EmergencyPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
|
||||
// Fetch workspace from IndexedDB for offline access
|
||||
const workspace = useLiveQuery(
|
||||
() => db.workspaces.get(currentWorkspace.id),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Fetch active medications
|
||||
const medications = useLiveQuery(
|
||||
() =>
|
||||
db.medications
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((m) => m.active && !m.deletedAt)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
if (!workspace) {
|
||||
return <LoadingState message="Loading emergency info..." />
|
||||
}
|
||||
|
||||
const emergencyInfo = {
|
||||
patientName: workspace.patientName,
|
||||
patientDOB: workspace.patientDOB,
|
||||
bloodType: workspace.bloodType,
|
||||
allergies: workspace.allergies,
|
||||
medicalConditions: workspace.medicalConditions,
|
||||
primaryPhysician: workspace.primaryPhysician,
|
||||
physicianPhone: workspace.physicianPhone,
|
||||
clinicPhone: workspace.clinicPhone,
|
||||
emergencyPhone: workspace.emergencyPhone,
|
||||
}
|
||||
|
||||
const hasInfo = emergencyInfo.patientName || emergencyInfo.bloodType ||
|
||||
emergencyInfo.allergies || emergencyInfo.medicalConditions
|
||||
|
||||
const medsList = medications?.map(m => ({
|
||||
name: m.name,
|
||||
instructions: m.instructions,
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-red-50">
|
||||
{/* Header */}
|
||||
<div className="bg-red-600 text-white safe-top">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<button
|
||||
onClick={() => router.push('/settings/emergency')}
|
||||
className="flex items-center gap-2 text-white/90 hover:text-white"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{hasInfo ? (
|
||||
<EmergencyCard info={emergencyInfo} medications={medsList} />
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Edit2 className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-2">
|
||||
No Emergency Info Set
|
||||
</h2>
|
||||
<p className="text-secondary-600 mb-4">
|
||||
Add important medical information for emergencies.
|
||||
</p>
|
||||
{currentWorkspace.role !== 'VIEWER' && (
|
||||
<Button onClick={() => router.push('/settings/emergency')}>
|
||||
Add Emergency Info
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Offline indicator */}
|
||||
<div className="fixed bottom-4 left-4 right-4">
|
||||
<div className="bg-green-100 border border-green-300 rounded-lg p-3 text-center">
|
||||
<p className="text-sm text-green-800 font-medium">
|
||||
This information is available offline
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||
import { ServiceWorkerRegistrar } from '@/components/notifications/ServiceWorkerRegistrar'
|
||||
import { AppProvider } from './provider'
|
||||
|
||||
export default async function AppLayout({
|
||||
@@ -36,6 +37,8 @@ export default async function AppLayout({
|
||||
clinicPhone: m.workspace.clinicPhone,
|
||||
emergencyPhone: m.workspace.emergencyPhone,
|
||||
largeTextMode: m.workspace.largeTextMode,
|
||||
quietHoursStart: m.workspace.quietHoursStart,
|
||||
quietHoursEnd: m.workspace.quietHoursEnd,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -44,6 +47,7 @@ export default async function AppLayout({
|
||||
workspaces={workspaces}
|
||||
initialWorkspaceId={workspaces[0].id}
|
||||
>
|
||||
<ServiceWorkerRegistrar />
|
||||
<div className={workspaces[0].largeTextMode ? 'large-text' : ''}>
|
||||
{children}
|
||||
<BottomNav />
|
||||
|
||||
247
src/app/(app)/meds/[id]/page.tsx
Normal file
247
src/app/(app)/meds/[id]/page.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { Pill, Clock, Edit2, Trash2, History } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
import { Card, Button, LoadingState, Modal, showToast, showUndoToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { RefillTracker } from '@/components/medications/RefillTracker'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
export default function MedicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: medicationId } = use(params)
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
// Fetch medication from IndexedDB
|
||||
const medication = useLiveQuery(
|
||||
() => db.medications.get(medicationId),
|
||||
[medicationId]
|
||||
)
|
||||
|
||||
// Fetch recent dose logs
|
||||
const doseLogs = useLiveQuery(
|
||||
() =>
|
||||
db.doseLogs
|
||||
.where('medicationId')
|
||||
.equals(medicationId)
|
||||
.reverse()
|
||||
.limit(10)
|
||||
.toArray(),
|
||||
[medicationId]
|
||||
)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await refreshData()
|
||||
}, [refreshData])
|
||||
|
||||
const handleTakeDose = useCallback(async () => {
|
||||
if (!medication) return
|
||||
try {
|
||||
const doseLog = await logDose(
|
||||
currentWorkspace.id,
|
||||
medication.id,
|
||||
{ id: medication.id, name: medication.name }
|
||||
)
|
||||
showUndoToast(`Took ${medication.name}`, async () => {
|
||||
await undoDose(doseLog)
|
||||
showToast('Dose undone', 'info')
|
||||
})
|
||||
} catch {
|
||||
showToast('Failed to log dose', 'error')
|
||||
}
|
||||
}, [medication, currentWorkspace.id])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!medication) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/medications/${medication.id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to delete')
|
||||
await refreshData()
|
||||
showToast('Medication deleted', 'success')
|
||||
router.push('/meds')
|
||||
} catch {
|
||||
showToast('Failed to delete medication', 'error')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setShowDeleteModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSchedule = () => {
|
||||
if (!medication) return ''
|
||||
const data = medication.scheduleData as Record<string, unknown>
|
||||
switch (medication.scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return `Daily at ${(data.times as string[]).join(', ')}`
|
||||
case 'INTERVAL':
|
||||
return `Every ${data.hours} hours (starting ${data.startTime})`
|
||||
case 'WEEKDAYS':
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const selectedDays = (data.days as number[]).map(d => days[d]).join(', ')
|
||||
return `${selectedDays} at ${data.time}`
|
||||
case 'PRN':
|
||||
return `As needed (min ${data.minHoursBetween}h between doses)`
|
||||
default:
|
||||
return medication.scheduleType
|
||||
}
|
||||
}
|
||||
|
||||
if (!medication) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Medication" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading medication..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const recentDoses = doseLogs?.filter(d => !d.undoneAt) || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title={medication.name}
|
||||
showBack
|
||||
rightAction={
|
||||
currentWorkspace.role !== 'VIEWER'
|
||||
? {
|
||||
icon: <Trash2 className="w-6 h-6 text-red-600" />,
|
||||
label: 'Delete',
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Status Card */}
|
||||
<Card className={medication.active ? '' : 'opacity-60'}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<Pill className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">
|
||||
{medication.name}
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatSchedule()}
|
||||
</p>
|
||||
{medication.instructions && (
|
||||
<p className="text-sm text-secondary-500 mt-2">
|
||||
{medication.instructions}
|
||||
</p>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<p className="text-sm text-orange-600 font-medium mt-2">
|
||||
Inactive
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentWorkspace.role !== 'VIEWER' && medication.active && (
|
||||
<Button
|
||||
onClick={handleTakeDose}
|
||||
variant="success"
|
||||
fullWidth
|
||||
className="mt-4"
|
||||
>
|
||||
Mark as Taken
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Refill Tracker */}
|
||||
{medication.pillCount !== null && (
|
||||
<RefillTracker
|
||||
medicationId={medication.id}
|
||||
workspaceId={currentWorkspace.id}
|
||||
medicationName={medication.name}
|
||||
pillCount={medication.pillCount}
|
||||
pillsPerDose={medication.pillsPerDose}
|
||||
refillThreshold={medication.refillThreshold}
|
||||
onRefill={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent Doses */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-600">Recent Doses</h3>
|
||||
<button
|
||||
onClick={() => router.push('/meds/history')}
|
||||
className="text-sm text-primary-600 font-medium flex items-center gap-1"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Full history
|
||||
</button>
|
||||
</div>
|
||||
{recentDoses.length > 0 ? (
|
||||
<Card padding="none">
|
||||
<ul className="divide-y divide-border">
|
||||
{recentDoses.map((dose) => (
|
||||
<li key={dose.id} className="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900">
|
||||
{format(new Date(dose.takenAt), 'EEEE, MMM d')}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
{format(new Date(dose.takenAt), 'h:mm a')}
|
||||
{dose.loggedBy && ` by ${dose.loggedBy.name}`}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : (
|
||||
<Card variant="outline" className="text-center py-6">
|
||||
<p className="text-secondary-500">No doses logged yet</p>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
title="Delete Medication"
|
||||
>
|
||||
<p className="text-secondary-600 mb-4">
|
||||
Are you sure you want to delete "{medication.name}"? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
fullWidth
|
||||
onClick={handleDelete}
|
||||
loading={deleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,12 @@ export default function NewMedicationPage() {
|
||||
// PRN
|
||||
const [minHoursBetween, setMinHoursBetween] = useState(4)
|
||||
|
||||
// Refill tracking (optional)
|
||||
const [trackRefills, setTrackRefills] = useState(false)
|
||||
const [pillCount, setPillCount] = useState<number | ''>('')
|
||||
const [pillsPerDose, setPillsPerDose] = useState(1)
|
||||
const [refillThreshold, setRefillThreshold] = useState(7)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -102,6 +108,12 @@ export default function NewMedicationPage() {
|
||||
scheduleType,
|
||||
scheduleData: buildScheduleData(),
|
||||
active: true,
|
||||
// Refill tracking (optional)
|
||||
...(trackRefills && pillCount !== '' && {
|
||||
pillCount: Number(pillCount),
|
||||
pillsPerDose,
|
||||
refillThreshold,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -248,6 +260,53 @@ export default function NewMedicationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Refill Tracking (optional) */}
|
||||
<div className="border-t border-border pt-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trackRefills"
|
||||
checked={trackRefills}
|
||||
onChange={(e) => setTrackRefills(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-border text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="trackRefills" className="text-sm font-medium text-secondary-700">
|
||||
Track pill count for refill reminders (optional)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trackRefills && (
|
||||
<div className="space-y-4 pl-8">
|
||||
<Input
|
||||
label="Current pill count"
|
||||
type="number"
|
||||
min={0}
|
||||
value={pillCount}
|
||||
onChange={(e) => setPillCount(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||
placeholder="e.g., 30"
|
||||
helperText="How many pills do you have now?"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Pills per dose"
|
||||
type="number"
|
||||
min={1}
|
||||
value={pillsPerDose}
|
||||
onChange={(e) => setPillsPerDose(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Input
|
||||
label="Alert when below"
|
||||
type="number"
|
||||
min={0}
|
||||
value={refillThreshold}
|
||||
onChange={(e) => setRefillThreshold(parseInt(e.target.value) || 7)}
|
||||
helperText="pills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plus, Pill, Clock, ChevronRight, History } from 'lucide-react'
|
||||
import { Plus, Pill, Clock, ChevronRight, History, AlertTriangle } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
@@ -10,6 +10,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
|
||||
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
|
||||
import { Card, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { RefillAlert } from '@/components/medications/RefillAlert'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function MedsPage() {
|
||||
@@ -119,16 +120,26 @@ export default function MedsPage() {
|
||||
onClick: () => router.push('/meds/new'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
<PageContainer className="pt-4 space-y-4">
|
||||
{/* History link */}
|
||||
<button
|
||||
onClick={() => router.push('/meds/history')}
|
||||
className="flex items-center gap-2 text-primary-600 font-medium mb-4"
|
||||
className="flex items-center gap-2 text-primary-600 font-medium"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
View dose history
|
||||
</button>
|
||||
|
||||
{/* Refill Alerts */}
|
||||
<RefillAlert
|
||||
medications={medications.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
pillCount: m.pillCount,
|
||||
refillThreshold: m.refillThreshold,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{medications.length === 0 ? (
|
||||
<EmptyState
|
||||
type="medications"
|
||||
@@ -206,6 +217,12 @@ interface MedicationCardProps {
|
||||
function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
|
||||
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
||||
|
||||
// Check for low pill count
|
||||
const med = medication as unknown as { pillCount?: number | null; refillThreshold?: number | null }
|
||||
const isLowOnPills = med.pillCount !== undefined && med.pillCount !== null &&
|
||||
med.refillThreshold !== undefined && med.refillThreshold !== null &&
|
||||
med.pillCount <= med.refillThreshold
|
||||
|
||||
const getTimeLabel = () => {
|
||||
if (isOverdue && nextDueAt) {
|
||||
return formatTimeUntil(nextDueAt, now)
|
||||
@@ -230,11 +247,18 @@ function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
|
||||
return (
|
||||
<Card className={isOverdue ? 'overdue' : ''} onClick={onClick}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Pill className="w-5 h-5 text-primary-600" />
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${isLowOnPills ? 'bg-orange-100' : 'bg-primary-100'}`}>
|
||||
<Pill className={`w-5 h-5 ${isLowOnPills ? 'text-orange-600' : 'text-primary-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
||||
{isLowOnPills && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 rounded">
|
||||
{med.pillCount} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm flex items-center gap-1 ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{getTimeLabel()}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toZonedTime } from 'date-fns-tz'
|
||||
import { HelpCircle, CheckCircle, Copy } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, markQuestionAsked } from '@/lib/sync'
|
||||
import { db, markQuestionAsked, unmarkQuestionAsked } from '@/lib/sync'
|
||||
import { Card, Button, LoadingState, EmptyState, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
@@ -46,6 +46,22 @@ export default function QuestionsPage() {
|
||||
[questions, refreshData]
|
||||
)
|
||||
|
||||
const handleUnmarkAsked = useCallback(
|
||||
async (noteId: string) => {
|
||||
const note = questions?.find((n) => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
try {
|
||||
await unmarkQuestionAsked(note)
|
||||
await refreshData()
|
||||
showToast('Moved back to "To Ask"', 'success')
|
||||
} catch {
|
||||
showToast('Failed to update', 'error')
|
||||
}
|
||||
},
|
||||
[questions, refreshData]
|
||||
)
|
||||
|
||||
const copyAllQuestions = () => {
|
||||
const text = unanswered.map((q) => `• ${q.content}`).join('\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
@@ -129,7 +145,7 @@ export default function QuestionsPage() {
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{answered.map((note) => (
|
||||
<Card key={note.id} className="opacity-60">
|
||||
<Card key={note.id} className="opacity-75">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
@@ -144,6 +160,13 @@ export default function QuestionsPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleUnmarkAsked(note.id)}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
157
src/app/(app)/print/appointments/page.tsx
Normal file
157
src/app/(app)/print/appointments/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format, isAfter, parseISO, addMonths } from 'date-fns'
|
||||
import { Printer, MapPin } from 'lucide-react'
|
||||
|
||||
import { Button, LoadingState } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
import '@/styles/print.css'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
mapUrl: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export default function AppointmentsPrintPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAppointments() {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/appointments`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Filter to upcoming appointments within next 3 months
|
||||
const now = new Date()
|
||||
const threeMonthsFromNow = addMonths(now, 3)
|
||||
const upcoming = data.appointments
|
||||
.filter((a: Appointment) => {
|
||||
const apptDate = parseISO(a.datetime)
|
||||
return isAfter(apptDate, now) && isAfter(threeMonthsFromNow, apptDate)
|
||||
})
|
||||
.sort(
|
||||
(a: Appointment, b: Appointment) =>
|
||||
parseISO(a.datetime).getTime() - parseISO(b.datetime).getTime()
|
||||
)
|
||||
setAppointments(upcoming)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch appointments:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAppointments()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Upcoming Appointments" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading appointments..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const today = format(new Date(), 'MMMM d, yyyy')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="screen-only">
|
||||
<Header title="Upcoming Appointments" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-secondary-600">Preview your printable appointments list</p>
|
||||
<Button onClick={handlePrint} className="flex items-center gap-2">
|
||||
<Printer className="w-4 h-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="print-preview p-8">
|
||||
<div className="print-title text-2xl font-bold mb-2">Upcoming Appointments</div>
|
||||
<div className="print-date text-gray-600 mb-6">Generated: {today}</div>
|
||||
|
||||
{currentWorkspace.name && (
|
||||
<div className="print-subtitle text-lg font-semibold mb-4">
|
||||
Patient: {currentWorkspace.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointments.length === 0 ? (
|
||||
<p className="text-gray-500">No upcoming appointments scheduled</p>
|
||||
) : (
|
||||
<table className="print-table w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 p-3 text-left font-semibold">Date & Time</th>
|
||||
<th className="border border-gray-300 p-3 text-left font-semibold">Appointment</th>
|
||||
<th className="border border-gray-300 p-3 text-left font-semibold">Location</th>
|
||||
<th className="border border-gray-300 p-3 text-left font-semibold">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{appointments.map((appt) => (
|
||||
<tr key={appt.id} className="print-no-break">
|
||||
<td className="border border-gray-300 p-3 whitespace-nowrap">
|
||||
<div className="font-semibold">
|
||||
{format(parseISO(appt.datetime), 'EEE, MMM d')}
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
{format(parseISO(appt.datetime), 'h:mm a')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-gray-300 p-3">
|
||||
<div className="font-medium">{appt.title}</div>
|
||||
</td>
|
||||
<td className="border border-gray-300 p-3">
|
||||
{appt.location && (
|
||||
<div className="flex items-start gap-1">
|
||||
<MapPin className="w-4 h-4 mt-0.5 text-gray-500 flex-shrink-0" />
|
||||
<span>{appt.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-300 p-3 text-gray-600 text-sm">
|
||||
{appt.notes || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-gray-300">
|
||||
<div className="print-subtitle font-semibold mb-2">Reminders:</div>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Bring insurance card and photo ID</li>
|
||||
<li>Arrive 15 minutes early for new appointments</li>
|
||||
<li>Bring a list of current medications</li>
|
||||
<li>Prepare questions for your doctor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="print-footer mt-8 text-center text-sm text-gray-500">
|
||||
Generated by NextStep - {today}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
208
src/app/(app)/print/daily-meds/page.tsx
Normal file
208
src/app/(app)/print/daily-meds/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Printer } from 'lucide-react'
|
||||
|
||||
import { Button, LoadingState } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
import '@/styles/print.css'
|
||||
|
||||
interface Medication {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string | null
|
||||
scheduleType: string
|
||||
scheduleData: {
|
||||
type: string
|
||||
times?: string[]
|
||||
hours?: number
|
||||
startTime?: string
|
||||
time?: string
|
||||
days?: number[]
|
||||
}
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface MedTime {
|
||||
time: string
|
||||
medications: { id: string; name: string; instructions: string | null }[]
|
||||
}
|
||||
|
||||
function parseScheduleTimes(med: Medication): string[] {
|
||||
const { scheduleType, scheduleData } = med
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return scheduleData.times || []
|
||||
case 'INTERVAL':
|
||||
// Generate times based on interval
|
||||
const times: string[] = []
|
||||
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
|
||||
const hours = scheduleData.hours || 4
|
||||
for (let h = startHour; h < 24; h += hours) {
|
||||
const hourStr = h.toString().padStart(2, '0')
|
||||
times.push(`${hourStr}:00`)
|
||||
}
|
||||
return times
|
||||
case 'WEEKDAYS':
|
||||
return scheduleData.time ? [scheduleData.time] : []
|
||||
case 'PRN':
|
||||
return ['As needed']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function groupMedicationsByTime(medications: Medication[]): MedTime[] {
|
||||
const timeMap = new Map<string, { id: string; name: string; instructions: string | null }[]>()
|
||||
|
||||
for (const med of medications) {
|
||||
if (!med.active) continue
|
||||
|
||||
const times = parseScheduleTimes(med)
|
||||
for (const time of times) {
|
||||
if (!timeMap.has(time)) {
|
||||
timeMap.set(time, [])
|
||||
}
|
||||
timeMap.get(time)!.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
instructions: med.instructions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
const sorted = Array.from(timeMap.entries())
|
||||
.filter(([time]) => time !== 'As needed')
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([time, medications]) => ({ time, medications }))
|
||||
|
||||
// Add PRN meds at the end
|
||||
const prnMeds = timeMap.get('As needed')
|
||||
if (prnMeds && prnMeds.length > 0) {
|
||||
sorted.push({ time: 'As needed', medications: prnMeds })
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
function formatTime(time: string): string {
|
||||
if (time === 'As needed') return time
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM'
|
||||
const displayHours = hours % 12 || 12
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export default function DailyMedsPrintPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
const [medications, setMedications] = useState<Medication[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMedications() {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/medications`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setMedications(data.medications.filter((m: Medication) => m.active))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch medications:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMedications()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Daily Medications" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading medications..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const medTimes = groupMedicationsByTime(medications)
|
||||
const today = format(new Date(), 'EEEE, MMMM d, yyyy')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="screen-only">
|
||||
<Header title="Daily Medications" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-secondary-600">Preview your printable medication schedule</p>
|
||||
<Button onClick={handlePrint} className="flex items-center gap-2">
|
||||
<Printer className="w-4 h-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="print-preview p-8">
|
||||
<div className="print-title text-2xl font-bold mb-2">Daily Medication Schedule</div>
|
||||
<div className="print-date text-gray-600 mb-6">{today}</div>
|
||||
|
||||
{currentWorkspace.name && (
|
||||
<div className="print-subtitle text-lg font-semibold mb-4">
|
||||
Patient: {currentWorkspace.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{medTimes.length === 0 ? (
|
||||
<p className="text-gray-500">No medications scheduled</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{medTimes.map((medTime, idx) => (
|
||||
<div key={idx} className="print-section print-no-break">
|
||||
<div className="flex items-center gap-3 mb-3 pb-2 border-b-2 border-gray-300">
|
||||
<span className="print-subtitle text-xl font-bold">
|
||||
{formatTime(medTime.time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{medTime.medications.map((med) => (
|
||||
<div key={med.id} className="print-med-item flex items-start gap-3 py-2">
|
||||
<div className="print-checkbox w-6 h-6 border-2 border-black flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="print-med-name text-lg font-semibold">{med.name}</div>
|
||||
{med.instructions && (
|
||||
<div className="print-text text-gray-600 text-sm mt-1">
|
||||
{med.instructions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-gray-300">
|
||||
<div className="print-subtitle font-semibold mb-2">Notes:</div>
|
||||
<div className="print-notes border border-gray-300 rounded p-3 min-h-[100px] bg-gray-50" />
|
||||
</div>
|
||||
|
||||
<div className="print-footer mt-8 text-center text-sm text-gray-500">
|
||||
Generated by NextStep - {today}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
323
src/app/(app)/print/doctor-visit/page.tsx
Normal file
323
src/app/(app)/print/doctor-visit/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format, subDays } from 'date-fns'
|
||||
import { Printer } from 'lucide-react'
|
||||
|
||||
import { Button, LoadingState } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
import '@/styles/print.css'
|
||||
|
||||
interface Medication {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string | null
|
||||
scheduleType: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Symptom {
|
||||
id: string
|
||||
type: string
|
||||
customName: string | null
|
||||
severity: number
|
||||
notes: string | null
|
||||
recordedAt: string
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
askedAt: string | null
|
||||
}
|
||||
|
||||
const SYMPTOM_LABELS: Record<string, string> = {
|
||||
FATIGUE: 'Fatigue',
|
||||
NAUSEA: 'Nausea',
|
||||
PAIN: 'Pain',
|
||||
APPETITE: 'Appetite Changes',
|
||||
SLEEP: 'Sleep Issues',
|
||||
MOOD: 'Mood Changes',
|
||||
CUSTOM: 'Other',
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
|
||||
|
||||
export default function DoctorVisitPrintPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
const [medications, setMedications] = useState<Medication[]>([])
|
||||
const [symptoms, setSymptoms] = useState<Symptom[]>([])
|
||||
const [questions, setQuestions] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch medications
|
||||
const medsResponse = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/medications`
|
||||
)
|
||||
if (medsResponse.ok) {
|
||||
const data = await medsResponse.json()
|
||||
setMedications(data.medications.filter((m: Medication) => m.active))
|
||||
}
|
||||
|
||||
// Fetch symptoms from last 30 days
|
||||
const symptomsResponse = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=50`
|
||||
)
|
||||
if (symptomsResponse.ok) {
|
||||
const data = await symptomsResponse.json()
|
||||
const thirtyDaysAgo = subDays(new Date(), 30)
|
||||
setSymptoms(
|
||||
data.symptoms.filter(
|
||||
(s: Symptom) => new Date(s.recordedAt) >= thirtyDaysAgo
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch questions (unasked notes)
|
||||
const notesResponse = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/notes`
|
||||
)
|
||||
if (notesResponse.ok) {
|
||||
const data = await notesResponse.json()
|
||||
setQuestions(
|
||||
data.notes.filter(
|
||||
(n: Note) => n.type === 'QUESTION' && !n.askedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Doctor's Visit Summary" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading summary..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const today = format(new Date(), 'MMMM d, yyyy')
|
||||
|
||||
// Group symptoms by type for summary
|
||||
const symptomSummary = symptoms.reduce(
|
||||
(acc, s) => {
|
||||
const key = s.type
|
||||
if (!acc[key]) {
|
||||
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
|
||||
}
|
||||
acc[key].count++
|
||||
acc[key].totalSeverity += s.severity
|
||||
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="screen-only">
|
||||
<Header title="Doctor's Visit Summary" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-secondary-600">
|
||||
Complete summary for your doctor appointment
|
||||
</p>
|
||||
<Button onClick={handlePrint} className="flex items-center gap-2">
|
||||
<Printer className="w-4 h-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
|
||||
<div className="print-preview p-8">
|
||||
<div className="print-title text-2xl font-bold mb-2">
|
||||
Doctor's Visit Summary
|
||||
</div>
|
||||
<div className="print-date text-gray-600 mb-6">Prepared: {today}</div>
|
||||
|
||||
{/* Patient Info */}
|
||||
<div className="print-section print-no-break mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="print-subtitle font-semibold mb-3">Patient Information</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Name:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{(currentWorkspace as any).patientName || currentWorkspace.name || '_______________'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">DOB:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{(currentWorkspace as any).patientDOB
|
||||
? format(new Date((currentWorkspace as any).patientDOB), 'MM/dd/yyyy')
|
||||
: '_______________'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Blood Type:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{(currentWorkspace as any).bloodType || '_______________'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(currentWorkspace as any).allergies && (
|
||||
<div className="mt-3">
|
||||
<span className="text-gray-600">Allergies:</span>{' '}
|
||||
<span className="font-medium text-red-600">
|
||||
{(currentWorkspace as any).allergies}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(currentWorkspace as any).medicalConditions && (
|
||||
<div className="mt-2">
|
||||
<span className="text-gray-600">Medical Conditions:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{(currentWorkspace as any).medicalConditions}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Medications */}
|
||||
<div className="print-section print-no-break mb-6">
|
||||
<div className="print-subtitle font-semibold mb-3">
|
||||
Current Medications ({medications.length})
|
||||
</div>
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No active medications</p>
|
||||
) : (
|
||||
<table className="print-table w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 p-2 text-left">Medication</th>
|
||||
<th className="border border-gray-300 p-2 text-left">Instructions</th>
|
||||
<th className="border border-gray-300 p-2 text-left">Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{medications.map((med) => (
|
||||
<tr key={med.id}>
|
||||
<td className="border border-gray-300 p-2 font-medium">
|
||||
{med.name}
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
{med.instructions || '-'}
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
{med.scheduleType.replace('_', ' ')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Symptom Summary */}
|
||||
<div className="print-section print-no-break mb-6">
|
||||
<div className="print-subtitle font-semibold mb-3">
|
||||
Symptoms (Last 30 Days)
|
||||
</div>
|
||||
{Object.keys(symptomSummary).length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No symptoms recorded</p>
|
||||
) : (
|
||||
<table className="print-table w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 p-2 text-left">Symptom</th>
|
||||
<th className="border border-gray-300 p-2 text-left">Occurrences</th>
|
||||
<th className="border border-gray-300 p-2 text-left">Avg Severity</th>
|
||||
<th className="border border-gray-300 p-2 text-left">Max Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(symptomSummary)
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([type, data]) => (
|
||||
<tr key={type}>
|
||||
<td className="border border-gray-300 p-2 font-medium">
|
||||
{SYMPTOM_LABELS[type] || type}
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">{data.count}x</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
{SEVERITY_LABELS[Math.round(data.totalSeverity / data.count) - 1]}
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
{SEVERITY_LABELS[data.maxSeverity - 1]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Questions to Ask */}
|
||||
<div className="print-section mb-6">
|
||||
<div className="print-subtitle font-semibold mb-3">
|
||||
Questions to Ask ({questions.length})
|
||||
</div>
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No questions prepared</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="print-question flex items-start gap-2 py-2">
|
||||
<div className="print-question-checkbox w-4 h-4 border-2 border-black flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{idx + 1}. {q.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Doctor's Notes Section */}
|
||||
<div className="print-section print-page-break">
|
||||
<div className="print-subtitle font-semibold mb-3">Doctor's Notes</div>
|
||||
<div className="print-notes border border-gray-300 rounded p-3 min-h-[200px] bg-gray-50" />
|
||||
</div>
|
||||
|
||||
{/* Follow-up */}
|
||||
<div className="print-section mt-6">
|
||||
<div className="print-subtitle font-semibold mb-3">Follow-up</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Next Appointment:</span>{' '}
|
||||
<span>________________________</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Labs Ordered:</span>{' '}
|
||||
<span>________________________</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="print-footer mt-8 text-center text-sm text-gray-500">
|
||||
Generated by NextStep - {today}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/app/(app)/print/page.tsx
Normal file
74
src/app/(app)/print/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { Printer, Pill, Calendar, Stethoscope } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Card } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
|
||||
const printOptions = [
|
||||
{
|
||||
href: '/print/daily-meds',
|
||||
icon: Pill,
|
||||
title: 'Daily Medication Schedule',
|
||||
description: 'Large checkboxes for tracking daily doses. Great for posting on the fridge.',
|
||||
},
|
||||
{
|
||||
href: '/print/appointments',
|
||||
icon: Calendar,
|
||||
title: 'Upcoming Appointments',
|
||||
description: 'List of upcoming appointments with dates, times, and locations.',
|
||||
},
|
||||
{
|
||||
href: '/print/doctor-visit',
|
||||
icon: Stethoscope,
|
||||
title: "Doctor's Visit Summary",
|
||||
description: 'Complete summary with medications, symptoms, and questions to ask.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function PrintPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Print" showBack />
|
||||
<PageContainer className="pt-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Printer className="w-6 h-6 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Print Documents</h2>
|
||||
</div>
|
||||
<p className="text-secondary-600 text-sm">
|
||||
Generate printable documents for caregiving, appointments, and medication tracking.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{printOptions.map((option) => (
|
||||
<Link key={option.href} href={option.href}>
|
||||
<Card className="hover:bg-secondary-50 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-secondary-100 rounded-lg">
|
||||
<option.icon className="w-5 h-5 text-secondary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-secondary-900">{option.title}</h3>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> After opening a print page, use your browser's print function
|
||||
(Ctrl/Cmd + P) to print or save as PDF.
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ interface Workspace {
|
||||
clinicPhone: string | null
|
||||
emergencyPhone: string | null
|
||||
largeTextMode: boolean
|
||||
quietHoursStart: string | null
|
||||
quietHoursEnd: string | null
|
||||
}
|
||||
|
||||
interface AppContextType {
|
||||
|
||||
219
src/app/(app)/settings/emergency/page.tsx
Normal file
219
src/app/(app)/settings/emergency/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
import { Input, Textarea, Select, Button, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
const BLOOD_TYPES = [
|
||||
{ value: '', label: 'Select blood type' },
|
||||
{ value: 'A+', label: 'A+' },
|
||||
{ value: 'A-', label: 'A-' },
|
||||
{ value: 'B+', label: 'B+' },
|
||||
{ value: 'B-', label: 'B-' },
|
||||
{ value: 'AB+', label: 'AB+' },
|
||||
{ value: 'AB-', label: 'AB-' },
|
||||
{ value: 'O+', label: 'O+' },
|
||||
{ value: 'O-', label: 'O-' },
|
||||
{ value: 'Unknown', label: 'Unknown' },
|
||||
]
|
||||
|
||||
export default function EmergencySettingsPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [patientName, setPatientName] = useState('')
|
||||
const [patientDOB, setPatientDOB] = useState('')
|
||||
const [bloodType, setBloodType] = useState('')
|
||||
const [allergies, setAllergies] = useState('')
|
||||
const [medicalConditions, setMedicalConditions] = useState('')
|
||||
const [primaryPhysician, setPrimaryPhysician] = useState('')
|
||||
const [physicianPhone, setPhysicianPhone] = useState('')
|
||||
const [clinicPhone, setClinicPhone] = useState('')
|
||||
const [emergencyPhone, setEmergencyPhone] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPatientName(data.patientName || '')
|
||||
setPatientDOB(data.patientDOB ? format(new Date(data.patientDOB), 'yyyy-MM-dd') : '')
|
||||
setBloodType(data.bloodType || '')
|
||||
setAllergies(data.allergies || '')
|
||||
setMedicalConditions(data.medicalConditions || '')
|
||||
setPrimaryPhysician(data.primaryPhysician || '')
|
||||
setPhysicianPhone(data.physicianPhone || '')
|
||||
setClinicPhone(data.clinicPhone || '')
|
||||
setEmergencyPhone(data.emergencyPhone || '')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load emergency info:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/emergency-info`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
patientName: patientName.trim() || null,
|
||||
patientDOB: patientDOB ? new Date(patientDOB).toISOString() : null,
|
||||
bloodType: bloodType || null,
|
||||
allergies: allergies.trim() || null,
|
||||
medicalConditions: medicalConditions.trim() || null,
|
||||
primaryPhysician: primaryPhysician.trim() || null,
|
||||
physicianPhone: physicianPhone.trim() || null,
|
||||
clinicPhone: clinicPhone.trim() || null,
|
||||
emergencyPhone: emergencyPhone.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
await refreshData()
|
||||
showToast('Emergency information saved', 'success')
|
||||
router.back()
|
||||
} catch (err) {
|
||||
showToast('Failed to save', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Emergency Info" showBack />
|
||||
<PageContainer className="pt-4 space-y-6 pb-24">
|
||||
<p className="text-secondary-600">
|
||||
This information will be available offline in emergencies. Fill in what you know.
|
||||
</p>
|
||||
|
||||
{/* Patient Information */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Patient Information
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Patient Name"
|
||||
value={patientName}
|
||||
onChange={(e) => setPatientName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Input
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
value={patientDOB}
|
||||
onChange={(e) => setPatientDOB(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Select
|
||||
label="Blood Type"
|
||||
value={bloodType}
|
||||
onChange={(e) => setBloodType(e.target.value)}
|
||||
options={BLOOD_TYPES}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Medical Information */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Medical Information
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
label="Allergies"
|
||||
value={allergies}
|
||||
onChange={(e) => setAllergies(e.target.value)}
|
||||
placeholder="List any allergies (medications, food, environmental)"
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Textarea
|
||||
label="Medical Conditions"
|
||||
value={medicalConditions}
|
||||
onChange={(e) => setMedicalConditions(e.target.value)}
|
||||
placeholder="List ongoing conditions (e.g., Cancer - receiving chemotherapy)"
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Healthcare Provider */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Healthcare Provider
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Primary Physician"
|
||||
value={primaryPhysician}
|
||||
onChange={(e) => setPrimaryPhysician(e.target.value)}
|
||||
placeholder="Doctor's name"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Input
|
||||
label="Physician Phone"
|
||||
type="tel"
|
||||
value={physicianPhone}
|
||||
onChange={(e) => setPhysicianPhone(e.target.value)}
|
||||
placeholder="e.g., 08 9400 1234"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Emergency Contacts */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Emergency Contacts
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Clinic Phone"
|
||||
type="tel"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="e.g., 08 9400 1234"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Input
|
||||
label="Emergency Contact (Family)"
|
||||
type="tel"
|
||||
value={emergencyPhone}
|
||||
onChange={(e) => setEmergencyPhone(e.target.value)}
|
||||
placeholder="e.g., 0412 345 678"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* Fixed Save Button */}
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-surface border-t border-border safe-bottom">
|
||||
<Button onClick={handleSave} fullWidth loading={saving} disabled={loading}>
|
||||
Save Emergency Info
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
144
src/app/(app)/settings/notifications/page.tsx
Normal file
144
src/app/(app)/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, Clock, Moon } from 'lucide-react'
|
||||
|
||||
import { Card, Button, Input, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { NotificationPermission } from '@/components/notifications/NotificationPermission'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
export default function NotificationsSettingsPage() {
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [quietStart, setQuietStart] = useState(currentWorkspace.quietHoursStart || '22:00')
|
||||
const [quietEnd, setQuietEnd] = useState(currentWorkspace.quietHoursEnd || '07:00')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSaveQuietHours = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
quietHoursStart: quietStart,
|
||||
quietHoursEnd: quietEnd,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save')
|
||||
|
||||
await refreshData()
|
||||
showToast('Quiet hours updated', 'success')
|
||||
} catch {
|
||||
showToast('Failed to save', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Notifications" showBack />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Push Notifications */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Push Notifications
|
||||
</h2>
|
||||
<Card>
|
||||
<NotificationPermission workspaceId={currentWorkspace.id} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Quiet Hours
|
||||
</h2>
|
||||
<Card>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 bg-indigo-100 rounded-lg">
|
||||
<Moon className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">
|
||||
Do Not Disturb
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
No reminders will be sent during quiet hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={quietStart}
|
||||
onChange={(e) => setQuietStart(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={quietEnd}
|
||||
onChange={(e) => setQuietEnd(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSaveQuietHours}
|
||||
loading={saving}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
>
|
||||
Save Quiet Hours
|
||||
</Button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
How It Works
|
||||
</h2>
|
||||
<Card variant="outline">
|
||||
<div className="space-y-4 text-sm text-secondary-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<Bell className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
When enabled, you'll receive push notifications when it's time
|
||||
to take your medications.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Reminders are sent based on your medication schedules.
|
||||
PRN (as-needed) medications don't trigger reminders.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Moon className="w-5 h-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Quiet hours prevent notifications from being sent during
|
||||
sleep or rest times.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
Shield,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Printer,
|
||||
Calendar,
|
||||
FileText,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
@@ -33,6 +39,7 @@ export default function SettingsPage() {
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [inviteUrl, setInviteUrl] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<'EDITOR' | 'VIEWER'>('VIEWER')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
|
||||
// Get workspace from IndexedDB for large text mode
|
||||
const workspace = useLiveQuery(
|
||||
@@ -153,6 +160,29 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
try {
|
||||
showToast('Generating PDF...', 'info')
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/export/summary.pdf`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate PDF')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `medical-summary-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast('PDF downloaded', 'success')
|
||||
} catch {
|
||||
showToast('PDF export failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Settings" />
|
||||
@@ -207,6 +237,50 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Emergency Info */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Emergency Information
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => router.push('/settings/emergency')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Medical Emergency Info</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
Blood type, allergies, conditions
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
History
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => router.push('/activity')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Activity className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Activity Log</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
View all changes and actions
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Family members */}
|
||||
{currentWorkspace.role === 'OWNER' && (
|
||||
<section>
|
||||
@@ -261,10 +335,78 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Notifications */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Notifications
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => router.push('/settings/notifications')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Medication Reminders</p>
|
||||
<p className="text-sm text-secondary-500">Push notifications & quiet hours</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Print */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Print</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => router.push('/print')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Printer className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Print Documents</p>
|
||||
<p className="text-sm text-secondary-500">Medication schedules, appointments</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Calendar Sync */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Calendar</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => setShowCalendarUrl(true)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Subscribe to Calendar</p>
|
||||
<p className="text-sm text-secondary-500">Sync appointments to iPhone, Google</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Data */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Data</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Medical Summary PDF</p>
|
||||
<p className="text-sm text-secondary-500">For doctor appointments</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
@@ -276,6 +418,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -412,6 +555,54 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Calendar URL modal */}
|
||||
<Modal
|
||||
isOpen={showCalendarUrl}
|
||||
onClose={() => setShowCalendarUrl(false)}
|
||||
title="Subscribe to Calendar"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Add your appointments to your phone's calendar app. This creates a subscription
|
||||
that stays up to date automatically.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Calendar URL
|
||||
</label>
|
||||
<div className="bg-muted p-3 rounded-button break-all text-sm text-secondary-700 font-mono">
|
||||
{typeof window !== 'undefined' &&
|
||||
`${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/api/workspaces/${currentWorkspace.id}/calendar.ics?token=${user.id}`
|
||||
navigator.clipboard.writeText(url)
|
||||
showToast('URL copied!', 'success')
|
||||
}}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy URL
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-blue-800 font-medium mb-2">How to subscribe:</p>
|
||||
<ul className="text-sm text-blue-700 list-disc list-inside space-y-1">
|
||||
<li><strong>iPhone:</strong> Settings → Calendar → Accounts → Add → Other → Add Subscribed Calendar</li>
|
||||
<li><strong>Google Calendar:</strong> Settings → Add calendar → From URL</li>
|
||||
<li><strong>Outlook:</strong> Add calendar → Subscribe from web</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
159
src/app/(app)/symptoms/history/page.tsx
Normal file
159
src/app/(app)/symptoms/history/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { subDays, format } from 'date-fns'
|
||||
|
||||
import { Card, LoadingState, Button } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { SymptomCard } from '@/components/symptoms/SymptomCard'
|
||||
import { SymptomChart } from '@/components/symptoms/SymptomChart'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
const SYMPTOM_TYPES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'FATIGUE', label: 'Fatigue' },
|
||||
{ value: 'NAUSEA', label: 'Nausea' },
|
||||
{ value: 'PAIN', label: 'Pain' },
|
||||
{ value: 'APPETITE', label: 'Appetite' },
|
||||
{ value: 'SLEEP', label: 'Sleep' },
|
||||
{ value: 'MOOD', label: 'Mood' },
|
||||
]
|
||||
|
||||
export default function SymptomsHistoryPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
const [symptoms, setSymptoms] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const fetchSymptoms = useCallback(async (reset = false) => {
|
||||
const currentOffset = reset ? 0 : offset
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: '50',
|
||||
offset: String(currentOffset),
|
||||
...(filterType && { type: filterType }),
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/symptoms?${params}`
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (reset) {
|
||||
setSymptoms(data.symptoms)
|
||||
setOffset(data.symptoms.length)
|
||||
} else {
|
||||
setSymptoms((prev) => [...prev, ...data.symptoms])
|
||||
setOffset(currentOffset + data.symptoms.length)
|
||||
}
|
||||
setHasMore(data.symptoms.length === 50)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch symptoms:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [currentWorkspace.id, filterType, offset])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSymptoms(true)
|
||||
}, [currentWorkspace.id, filterType])
|
||||
|
||||
const handleFilterChange = (type: string) => {
|
||||
setFilterType(type)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Symptom History" showBack />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading history..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Symptom History" showBack />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{SYMPTOM_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => handleFilterChange(type.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
filterType === type.value
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 30-Day Chart */}
|
||||
{symptoms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Last 30 Days
|
||||
</h2>
|
||||
<Card>
|
||||
<SymptomChart
|
||||
symptoms={symptoms}
|
||||
days={30}
|
||||
type={filterType || undefined}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Symptoms List */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
All Entries
|
||||
</h2>
|
||||
{symptoms.length === 0 ? (
|
||||
<Card variant="outline" className="text-center py-8">
|
||||
<p className="text-secondary-500">No symptoms found</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{symptoms.map((symptom) => (
|
||||
<SymptomCard key={symptom.id} symptom={symptom} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{hasMore && (
|
||||
<div className="text-center pb-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fetchSymptoms(false)}
|
||||
loading={loadingMore}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
150
src/app/(app)/symptoms/page.tsx
Normal file
150
src/app/(app)/symptoms/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BarChart2, History } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { Card, LoadingState, EmptyState } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { SymptomQuickLog } from '@/components/symptoms/SymptomQuickLog'
|
||||
import { SymptomCard } from '@/components/symptoms/SymptomCard'
|
||||
import { SymptomChart } from '@/components/symptoms/SymptomChart'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function SymptomsPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [recentSymptoms, setRecentSymptoms] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch symptoms from IndexedDB
|
||||
const localSymptoms = useLiveQuery(
|
||||
() =>
|
||||
db.symptoms
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((s) => !s.deletedAt)
|
||||
.reverse()
|
||||
.limit(50)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Also fetch from server to get the latest
|
||||
const fetchSymptoms = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/symptoms?limit=20`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setRecentSymptoms(data.symptoms)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch symptoms:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [currentWorkspace.id])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSymptoms()
|
||||
}, [fetchSymptoms])
|
||||
|
||||
const handleLogged = () => {
|
||||
fetchSymptoms()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
// Combine local and server data, preferring server
|
||||
const symptoms = recentSymptoms.length > 0 ? recentSymptoms : localSymptoms || []
|
||||
|
||||
if (loading && !localSymptoms) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Symptoms" />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading symptoms..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Symptoms"
|
||||
rightAction={{
|
||||
icon: <History className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'History',
|
||||
onClick: () => router.push('/symptoms/history'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Quick Log */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Log a Symptom</h2>
|
||||
<Card>
|
||||
<SymptomQuickLog
|
||||
workspaceId={currentWorkspace.id}
|
||||
onLogged={handleLogged}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* 7-Day Chart */}
|
||||
{symptoms.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-3">
|
||||
Last 7 Days
|
||||
</h2>
|
||||
<Card>
|
||||
<SymptomChart
|
||||
symptoms={symptoms.map((s: any) => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
severity: s.severity,
|
||||
recordedAt: s.recordedAt,
|
||||
}))}
|
||||
days={7}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Symptoms */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Recent</h2>
|
||||
{symptoms.length > 5 && (
|
||||
<button
|
||||
onClick={() => router.push('/symptoms/history')}
|
||||
className="text-sm text-primary-600 font-medium"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{symptoms.length === 0 ? (
|
||||
<Card variant="outline" className="text-center py-8">
|
||||
<BarChart2 className="w-10 h-10 text-secondary-300 mx-auto mb-3" />
|
||||
<p className="text-secondary-500">No symptoms logged yet</p>
|
||||
<p className="text-sm text-secondary-400 mt-1">
|
||||
Use the form above to track how you're feeling
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{symptoms.slice(0, 5).map((symptom: any) => (
|
||||
<SymptomCard key={symptom.id} symptom={symptom} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format, isToday, isTomorrow } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus } from 'lucide-react'
|
||||
import { Phone, MapPin, Clock, ChevronRight, Pill, Calendar, Plus, AlertTriangle, ClipboardCheck } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
@@ -12,6 +12,7 @@ import { calculateAllMedicationsDue, formatTimeUntil } from '@/lib/schedule'
|
||||
import type { Medication, DoseLog, MedicationDueStatus } from '@/lib/schedule'
|
||||
import { Card, CardTitle, Button, LoadingState, EmptyState, showUndoToast, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { RefillAlert } from '@/components/medications/RefillAlert'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
@@ -173,21 +174,38 @@ export default function TodayPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Emergency & Call Clinic Buttons */}
|
||||
<div className="flex gap-3">
|
||||
{/* Emergency Info Button */}
|
||||
<button
|
||||
onClick={() => router.push('/emergency')}
|
||||
className="flex items-center gap-3 p-4 bg-red-50 rounded-card border border-red-200 hover:bg-red-100 transition-colors flex-1"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-red-800">Emergency</p>
|
||||
<p className="text-sm text-red-600">Medical info</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Call Clinic Button */}
|
||||
{currentWorkspace.clinicPhone && (
|
||||
<a
|
||||
href={`tel:${currentWorkspace.clinicPhone}`}
|
||||
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors"
|
||||
className="flex items-center gap-3 p-4 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors flex-1"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-primary-800">Call Clinic</p>
|
||||
<p className="text-sm text-primary-600">{currentWorkspace.clinicPhone}</p>
|
||||
<p className="text-sm text-primary-600 truncate">{currentWorkspace.clinicPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Appointment */}
|
||||
<section>
|
||||
@@ -258,6 +276,51 @@ export default function TodayPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Prep Reminder for Tomorrow's Appointment */}
|
||||
{appointments && appointments.length > 0 && (() => {
|
||||
const tomorrowAppt = appointments.find((appt) =>
|
||||
isTomorrow(toZonedTime(new Date(appt.datetime), TIMEZONE))
|
||||
)
|
||||
if (tomorrowAppt) {
|
||||
return (
|
||||
<section>
|
||||
<Card
|
||||
className="bg-green-50 border border-green-200 cursor-pointer hover:bg-green-100 transition-colors"
|
||||
onClick={() => router.push(`/appointments/${tomorrowAppt.id}/prep`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0">
|
||||
<ClipboardCheck className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-green-800">
|
||||
Prepare for tomorrow
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
{tomorrowAppt.title} at {format(toZonedTime(new Date(tomorrowAppt.datetime), TIMEZONE), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{/* Refill Alerts */}
|
||||
{medications && medications.length > 0 && (
|
||||
<RefillAlert
|
||||
medications={medications.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
pillCount: m.pillCount,
|
||||
refillThreshold: m.refillThreshold,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Meds Due */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
48
src/app/api/notifications/send/route.ts
Normal file
48
src/app/api/notifications/send/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sendDueNotifications } from '@/lib/notifications/scheduler'
|
||||
|
||||
// This endpoint should be called by a cron job every minute
|
||||
// You can set up a cron service like:
|
||||
// - Vercel Cron Jobs
|
||||
// - AWS EventBridge
|
||||
// - A simple setInterval in a long-running process
|
||||
|
||||
// POST /api/notifications/send - Trigger sending due notifications
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Verify cron secret to prevent unauthorized access
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// If CRON_SECRET is set, verify it
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { sent, failed } = await sendDueNotifications()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sent,
|
||||
failed,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Send notifications error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send notifications' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint for health checks
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
message: 'Notification sender is ready',
|
||||
})
|
||||
}
|
||||
106
src/app/api/notifications/subscribe/route.ts
Normal file
106
src/app/api/notifications/subscribe/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { getPublicVAPIDKey } from '@/lib/notifications/push'
|
||||
|
||||
// GET /api/notifications/subscribe - Get VAPID public key
|
||||
export const GET = withAuth(async () => {
|
||||
const publicKey = getPublicVAPIDKey()
|
||||
|
||||
if (!publicKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications not configured' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ publicKey })
|
||||
})
|
||||
|
||||
// POST /api/notifications/subscribe - Subscribe to push notifications
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { subscription, workspaceId } = body
|
||||
|
||||
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid subscription data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Upsert the subscription (update if exists, create if not)
|
||||
const existing = await prisma.pushSubscription.findFirst({
|
||||
where: {
|
||||
endpoint: subscription.endpoint,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
workspaceId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.pushSubscription.create({
|
||||
data: {
|
||||
userId: req.session.user.id,
|
||||
workspaceId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Subscribe error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to subscribe' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/notifications/subscribe - Unsubscribe from push notifications
|
||||
export const DELETE = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endpoint required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
endpoint,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Unsubscribe error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to unsubscribe' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -33,7 +33,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
const sinceDate = new Date(since)
|
||||
|
||||
// Fetch all changed entities
|
||||
const [appointments, medications, notes, doseLogs, workspace] = await Promise.all([
|
||||
const [appointments, medications, notes, doseLogs, symptoms, workspace] = await Promise.all([
|
||||
prisma.appointment.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
@@ -63,6 +63,12 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
undoneBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.symptom.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
@@ -74,13 +80,21 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
quietHoursEnd: true,
|
||||
largeTextMode: true,
|
||||
updatedAt: true,
|
||||
// Emergency info fields
|
||||
patientName: true,
|
||||
patientDOB: true,
|
||||
bloodType: true,
|
||||
allergies: true,
|
||||
medicalConditions: true,
|
||||
primaryPhysician: true,
|
||||
physicianPhone: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Calculate new cursor (latest syncedAt timestamp)
|
||||
let cursor = since
|
||||
const allItems = [...appointments, ...medications, ...notes, ...doseLogs]
|
||||
const allItems = [...appointments, ...medications, ...notes, ...doseLogs, ...symptoms]
|
||||
for (const item of allItems) {
|
||||
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
|
||||
if (itemTime > cursor) {
|
||||
@@ -94,6 +108,7 @@ export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
medications,
|
||||
notes,
|
||||
doseLogs,
|
||||
symptoms,
|
||||
cursor,
|
||||
hasConflicts: false, // For now, always false - client handles conflicts
|
||||
})
|
||||
@@ -296,6 +311,64 @@ export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'UNMARK_ASKED': {
|
||||
if (!op.entityId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
||||
break
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
askedAt: null,
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true })
|
||||
break
|
||||
}
|
||||
|
||||
case 'LOG_SYMPTOM': {
|
||||
if (!op.data) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing symptom data' })
|
||||
break
|
||||
}
|
||||
|
||||
const symptom = await prisma.symptom.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: op.data.type as 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM',
|
||||
customName: (op.data.customName as string) || null,
|
||||
severity: op.data.severity as number,
|
||||
notes: (op.data.notes as string) || null,
|
||||
recordedAt: op.data.recordedAt ? new Date(op.data.recordedAt as string) : new Date(),
|
||||
createdById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: symptom.id })
|
||||
break
|
||||
}
|
||||
|
||||
case 'DELETE_SYMPTOM': {
|
||||
if (!op.entityId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
||||
break
|
||||
}
|
||||
|
||||
await prisma.symptom.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true })
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
results.push({ opId: op.id, success: false, error: 'Unknown operation type' })
|
||||
}
|
||||
|
||||
55
src/app/api/workspaces/[id]/activity/route.ts
Normal file
55
src/app/api/workspaces/[id]/activity/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
|
||||
// GET /api/workspaces/[id]/activity
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100)
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
const entityType = searchParams.get('entityType')
|
||||
const action = searchParams.get('action')
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(entityType ? { entityType } : {}),
|
||||
...(action ? { action } : {}),
|
||||
}
|
||||
|
||||
const [activities, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
activities,
|
||||
total,
|
||||
hasMore: offset + activities.length < total,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get activity error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get activity' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
|
||||
// GET /api/workspaces/[id]/appointments/[appointmentId]/checklist
|
||||
export const GET = withAuth(
|
||||
async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, appointmentId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify appointment exists
|
||||
const appointment = await prisma.appointment.findFirst({
|
||||
where: { id: appointmentId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!appointment) {
|
||||
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch checklist items
|
||||
const checklists = await prisma.appointmentChecklist.findMany({
|
||||
where: { workspaceId, appointmentId },
|
||||
})
|
||||
|
||||
// Convert to a map of { itemId: isReady }
|
||||
const checkedItems: Record<string, boolean> = {}
|
||||
for (const item of checklists) {
|
||||
checkedItems[item.item] = item.isReady
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
checkedItems,
|
||||
customItems: [], // Future: support custom items
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Checklist get error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get checklist' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// POST /api/workspaces/[id]/appointments/[appointmentId]/checklist
|
||||
export const POST = withAuth(
|
||||
async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, appointmentId } = await params
|
||||
const body = await req.json()
|
||||
const { checkedItems } = body as { checkedItems: Record<string, boolean> }
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, [
|
||||
'OWNER',
|
||||
'EDITOR',
|
||||
])
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify appointment exists
|
||||
const appointment = await prisma.appointment.findFirst({
|
||||
where: { id: appointmentId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!appointment) {
|
||||
return NextResponse.json({ error: 'Appointment not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Upsert each checklist item
|
||||
for (const [itemId, isReady] of Object.entries(checkedItems)) {
|
||||
await prisma.appointmentChecklist.upsert({
|
||||
where: {
|
||||
workspaceId_appointmentId_item: {
|
||||
workspaceId,
|
||||
appointmentId,
|
||||
item: itemId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
appointmentId,
|
||||
item: itemId,
|
||||
isReady: isReady as boolean,
|
||||
},
|
||||
update: {
|
||||
isReady: isReady as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Checklist save error:', error)
|
||||
return NextResponse.json({ error: 'Failed to save checklist' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
71
src/app/api/workspaces/[id]/calendar.ics/route.ts
Normal file
71
src/app/api/workspaces/[id]/calendar.ics/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { generateICalendar } from '@/lib/calendar/ical-generator'
|
||||
|
||||
// GET /api/workspaces/[id]/calendar.ics - Get iCal feed
|
||||
// This endpoint uses a token-based auth for calendar subscription
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const token = searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return new NextResponse('Unauthorized - token required', { status: 401 })
|
||||
}
|
||||
|
||||
// Verify the token is a valid workspace membership
|
||||
// Token format: userId (simplified for now - could be JWT in production)
|
||||
const membership = await prisma.workspaceMember.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: token,
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!membership) {
|
||||
return new NextResponse('Unauthorized - invalid token', { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch appointments (non-deleted, future and recent)
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
datetime: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
// Generate iCal
|
||||
const icalContent = generateICalendar(
|
||||
appointments.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
datetime: a.datetime,
|
||||
location: a.location,
|
||||
notes: a.notes,
|
||||
})),
|
||||
membership.workspace.name
|
||||
)
|
||||
|
||||
return new NextResponse(icalContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${membership.workspace.name}-appointments.ics"`,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('iCal generation error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,20 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
},
|
||||
})
|
||||
|
||||
// Decrement pill count if tracking is enabled
|
||||
if (medication.pillCount !== null && medication.pillsPerDose !== null) {
|
||||
const newCount = Math.max(0, medication.pillCount - (medication.pillsPerDose || 1))
|
||||
await prisma.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: {
|
||||
pillCount: newCount,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
@@ -165,7 +179,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
undoneAt: null,
|
||||
},
|
||||
include: {
|
||||
medication: { select: { name: true } },
|
||||
medication: { select: { id: true, name: true, pillCount: true, pillsPerDose: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -198,6 +212,20 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
},
|
||||
})
|
||||
|
||||
// Restore pill count if tracking is enabled
|
||||
if (doseLog.medication.pillCount !== null && doseLog.medication.pillsPerDose !== null) {
|
||||
const newCount = doseLog.medication.pillCount + (doseLog.medication.pillsPerDose || 1)
|
||||
await prisma.medication.update({
|
||||
where: { id: doseLog.medication.id },
|
||||
data: {
|
||||
pillCount: newCount,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
|
||||
132
src/app/api/workspaces/[id]/emergency-info/route.ts
Normal file
132
src/app/api/workspaces/[id]/emergency-info/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { emergencyInfoSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/emergency-info
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
patientName: true,
|
||||
patientDOB: true,
|
||||
bloodType: true,
|
||||
allergies: true,
|
||||
medicalConditions: true,
|
||||
primaryPhysician: true,
|
||||
physicianPhone: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Also fetch active medications for the emergency view
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
active: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
instructions: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
...workspace,
|
||||
medications,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get emergency info error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get emergency info' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/emergency-info
|
||||
export const PATCH = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = emergencyInfoSchema.safeParse(body)
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const data = result.data
|
||||
const workspace = await prisma.workspace.update({
|
||||
where: { id: workspaceId },
|
||||
data: {
|
||||
patientName: data.patientName,
|
||||
patientDOB: data.patientDOB ? new Date(data.patientDOB) : null,
|
||||
bloodType: data.bloodType,
|
||||
allergies: data.allergies,
|
||||
medicalConditions: data.medicalConditions,
|
||||
primaryPhysician: data.primaryPhysician,
|
||||
physicianPhone: data.physicianPhone,
|
||||
clinicPhone: data.clinicPhone,
|
||||
emergencyPhone: data.emergencyPhone,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
patientName: true,
|
||||
patientDOB: true,
|
||||
bloodType: true,
|
||||
allergies: true,
|
||||
medicalConditions: true,
|
||||
primaryPhysician: true,
|
||||
physicianPhone: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'WORKSPACE',
|
||||
entityId: workspaceId,
|
||||
details: { updated: 'emergency_info' },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(workspace)
|
||||
} catch (error) {
|
||||
console.error('Update emergency info error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update emergency info' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
107
src/app/api/workspaces/[id]/export/summary.pdf/route.ts
Normal file
107
src/app/api/workspaces/[id]/export/summary.pdf/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { generateMedicalSummaryPDF } from '@/lib/export/pdf-generator'
|
||||
|
||||
// GET /api/workspaces/[id]/export/summary.pdf - Generate PDF summary
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }: { params: Promise<Record<string, string>> }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Fetch all data
|
||||
const [workspace, medications, appointments, symptoms] = await Promise.all([
|
||||
prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
name: true,
|
||||
patientName: true,
|
||||
patientDOB: true,
|
||||
bloodType: true,
|
||||
allergies: true,
|
||||
medicalConditions: true,
|
||||
primaryPhysician: true,
|
||||
physicianPhone: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { workspaceId, deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
prisma.appointment.findMany({
|
||||
where: { workspaceId, deletedAt: null },
|
||||
orderBy: { datetime: 'asc' },
|
||||
}),
|
||||
prisma.symptom.findMany({
|
||||
where: { workspaceId, deletedAt: null },
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
take: 100, // Last 100 symptoms
|
||||
}),
|
||||
])
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
const doc = generateMedicalSummaryPDF({
|
||||
patient: workspace,
|
||||
medications: medications.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
instructions: m.instructions,
|
||||
scheduleType: m.scheduleType,
|
||||
active: m.active,
|
||||
})),
|
||||
appointments: appointments.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
datetime: a.datetime,
|
||||
location: a.location,
|
||||
notes: a.notes,
|
||||
})),
|
||||
symptoms: symptoms.map((s) => ({
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
customName: s.customName,
|
||||
severity: s.severity,
|
||||
notes: s.notes,
|
||||
recordedAt: s.recordedAt,
|
||||
})),
|
||||
generatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Convert PDF to buffer
|
||||
const chunks: Buffer[] = []
|
||||
doc.on('data', (chunk) => chunks.push(chunk))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
doc.on('end', () => resolve())
|
||||
doc.on('error', (err) => reject(err))
|
||||
doc.end()
|
||||
})
|
||||
|
||||
const pdfBuffer = Buffer.concat(chunks)
|
||||
|
||||
const filename = `${workspace.patientName || workspace.name || 'medical'}-summary-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
|
||||
return new NextResponse(pdfBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': String(pdfBuffer.length),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PDF generation error:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate PDF' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
|
||||
const refillAmountSchema = z.object({
|
||||
amount: z.number().min(1, 'Amount must be at least 1'),
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/medications/[medicationId]/refill
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, medicationId } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = refillAmountSchema.safeParse(body)
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { amount } = result.data
|
||||
|
||||
// Get current medication
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: {
|
||||
id: medicationId,
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!medication) {
|
||||
return NextResponse.json({ error: 'Medication not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update pill count
|
||||
const newPillCount = (medication.pillCount ?? 0) + amount
|
||||
const updated = await prisma.medication.update({
|
||||
where: { id: medicationId },
|
||||
data: {
|
||||
pillCount: newPillCount,
|
||||
lastRefillDate: new Date(),
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'REFILL',
|
||||
entityType: 'MEDICATION',
|
||||
entityId: medicationId,
|
||||
details: {
|
||||
amount,
|
||||
previousCount: medication.pillCount,
|
||||
newCount: newPillCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
pillCount: updated.pillCount,
|
||||
lastRefillDate: updated.lastRefillDate,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Refill error:', error)
|
||||
return NextResponse.json({ error: 'Failed to record refill' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { medicationSchema } from '@/lib/validation'
|
||||
import { medicationWithRefillSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/medications/[medicationId]
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
@@ -75,7 +75,7 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = medicationSchema.partial().safeParse(body)
|
||||
const result = medicationWithRefillSchema.partial().safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
@@ -97,6 +97,9 @@ export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
if (result.data.endDate !== undefined) {
|
||||
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
|
||||
}
|
||||
if (result.data.lastRefillDate !== undefined) {
|
||||
updateData.lastRefillDate = result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null
|
||||
}
|
||||
|
||||
const medication = await prisma.medication.update({
|
||||
where: { id: medicationId },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { medicationSchema } from '@/lib/validation'
|
||||
import { medicationWithRefillSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/medications - List medications
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
@@ -60,7 +60,7 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = medicationSchema.safeParse(body)
|
||||
const result = medicationWithRefillSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
@@ -79,6 +79,11 @@ export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
startDate: result.data.startDate ? new Date(result.data.startDate) : null,
|
||||
endDate: result.data.endDate ? new Date(result.data.endDate) : null,
|
||||
active: result.data.active ?? true,
|
||||
// Refill tracking fields
|
||||
pillCount: result.data.pillCount ?? null,
|
||||
pillsPerDose: result.data.pillsPerDose ?? 1,
|
||||
refillThreshold: result.data.refillThreshold ?? 7,
|
||||
lastRefillDate: result.data.lastRefillDate ? new Date(result.data.lastRefillDate) : null,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
|
||||
85
src/app/api/workspaces/[id]/symptoms/[symptomId]/route.ts
Normal file
85
src/app/api/workspaces/[id]/symptoms/[symptomId]/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { symptomSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/symptoms/[symptomId]
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, symptomId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const symptom = await prisma.symptom.findFirst({
|
||||
where: { id: symptomId, workspaceId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!symptom) {
|
||||
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ symptom })
|
||||
} catch (error) {
|
||||
console.error('Get symptom error:', error)
|
||||
return NextResponse.json({ error: 'Failed to get symptom' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/symptoms/[symptomId] (soft delete)
|
||||
export const DELETE = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId, symptomId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || !canEdit(access.role)) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const existing = await prisma.symptom.findFirst({
|
||||
where: { id: symptomId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Symptom not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.symptom.update({
|
||||
where: { id: symptomId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SYMPTOM',
|
||||
entityId: symptomId,
|
||||
details: { type: existing.type },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Symptom deleted' })
|
||||
} catch (error) {
|
||||
console.error('Delete symptom error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete symptom' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
110
src/app/api/workspaces/[id]/symptoms/route.ts
Normal file
110
src/app/api/workspaces/[id]/symptoms/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { checkWorkspaceAccess, canEdit } from '@/lib/db/workspace-access'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { symptomSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/symptoms
|
||||
export const GET = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const includeDeleted = searchParams.get('includeDeleted') === 'true'
|
||||
const type = searchParams.get('type')
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(includeDeleted ? {} : { deletedAt: null }),
|
||||
...(type ? { type } : {}),
|
||||
}
|
||||
|
||||
if (from || to) {
|
||||
where.recordedAt = {}
|
||||
if (from) (where.recordedAt as Record<string, unknown>).gte = new Date(from)
|
||||
if (to) (where.recordedAt as Record<string, unknown>).lte = new Date(to)
|
||||
}
|
||||
|
||||
const symptoms = await prisma.symptom.findMany({
|
||||
where,
|
||||
orderBy: { recordedAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ symptoms })
|
||||
} catch (error) {
|
||||
console.error('List symptoms error:', error)
|
||||
return NextResponse.json({ error: 'Failed to list symptoms' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/symptoms
|
||||
export const POST = withAuth(async (
|
||||
req: AuthenticatedRequest,
|
||||
{ params }: { params: Promise<Record<string, string>> }
|
||||
) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access || !canEdit(access.role)) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = symptomSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const symptom = await prisma.symptom.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: result.data.type,
|
||||
customName: result.data.customName || null,
|
||||
severity: result.data.severity,
|
||||
notes: result.data.notes || null,
|
||||
recordedAt: result.data.recordedAt ? new Date(result.data.recordedAt) : new Date(),
|
||||
createdById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SYMPTOM',
|
||||
entityId: symptom.id,
|
||||
details: { type: symptom.type, severity: symptom.severity },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ symptom }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create symptom error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create symptom' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '../styles/print.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get('redirect')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -33,7 +35,8 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
showToast('Welcome back!', 'success')
|
||||
router.push('/today')
|
||||
// If there's a redirect param (e.g., from invite link), go there
|
||||
router.push(redirectTo || '/today')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
@@ -97,3 +100,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||
|
||||
export default function RegisterPage() {
|
||||
function RegisterForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get('redirect')
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -46,7 +48,8 @@ export default function RegisterPage() {
|
||||
}
|
||||
|
||||
showToast('Account created! Let\'s get started.', 'success')
|
||||
router.push('/onboarding')
|
||||
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
|
||||
router.push(redirectTo || '/onboarding')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
@@ -128,3 +131,15 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/components/activity/ActivityFilter.tsx
Normal file
34
src/components/activity/ActivityFilter.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
interface ActivityFilterProps {
|
||||
entityType: string
|
||||
onEntityTypeChange: (type: string) => void
|
||||
}
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'MEDICATION', label: 'Medications' },
|
||||
{ value: 'APPOINTMENT', label: 'Appointments' },
|
||||
{ value: 'NOTE', label: 'Notes' },
|
||||
{ value: 'DOSE_LOG', label: 'Doses' },
|
||||
]
|
||||
|
||||
export function ActivityFilter({ entityType, onEntityTypeChange }: ActivityFilterProps) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{ENTITY_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
onClick={() => onEntityTypeChange(type.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
entityType === type.value
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
src/components/activity/ActivityItem.tsx
Normal file
96
src/components/activity/ActivityItem.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { Pill, Calendar, FileText, CheckCircle, XCircle, Plus, Edit2, Trash2, RefreshCw, HelpCircle } from 'lucide-react'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
details: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
user: { id: string; name: string }
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, React.ReactNode> = {
|
||||
CREATE: <Plus className="w-4 h-4" />,
|
||||
UPDATE: <Edit2 className="w-4 h-4" />,
|
||||
DELETE: <Trash2 className="w-4 h-4" />,
|
||||
TAKE_DOSE: <CheckCircle className="w-4 h-4" />,
|
||||
UNDO_DOSE: <XCircle className="w-4 h-4" />,
|
||||
MARK_ASKED: <HelpCircle className="w-4 h-4" />,
|
||||
REFILL: <RefreshCw className="w-4 h-4" />,
|
||||
}
|
||||
|
||||
const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||
MEDICATION: <Pill className="w-5 h-5" />,
|
||||
APPOINTMENT: <Calendar className="w-5 h-5" />,
|
||||
NOTE: <FileText className="w-5 h-5" />,
|
||||
DOSE_LOG: <Pill className="w-5 h-5" />,
|
||||
WORKSPACE: <Edit2 className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
CREATE: 'bg-green-100 text-green-600',
|
||||
UPDATE: 'bg-blue-100 text-blue-600',
|
||||
DELETE: 'bg-red-100 text-red-600',
|
||||
TAKE_DOSE: 'bg-primary-100 text-primary-600',
|
||||
UNDO_DOSE: 'bg-orange-100 text-orange-600',
|
||||
MARK_ASKED: 'bg-purple-100 text-purple-600',
|
||||
REFILL: 'bg-teal-100 text-teal-600',
|
||||
}
|
||||
|
||||
function getActivityDescription(activity: Activity): string {
|
||||
const details = activity.details || {}
|
||||
const name = details.name || details.medicationName || details.title || 'item'
|
||||
|
||||
switch (activity.action) {
|
||||
case 'CREATE':
|
||||
return `Added ${activity.entityType.toLowerCase()} "${name}"`
|
||||
case 'UPDATE':
|
||||
if (details.updated === 'emergency_info') {
|
||||
return 'Updated emergency information'
|
||||
}
|
||||
return `Updated ${activity.entityType.toLowerCase()} "${name}"`
|
||||
case 'DELETE':
|
||||
return `Deleted ${activity.entityType.toLowerCase()} "${name}"`
|
||||
case 'TAKE_DOSE':
|
||||
return `Took ${name}`
|
||||
case 'UNDO_DOSE':
|
||||
return `Undid dose of ${name}`
|
||||
case 'MARK_ASKED':
|
||||
return 'Marked question as asked'
|
||||
case 'REFILL':
|
||||
return `Refilled ${name} (+${details.amount} pills)`
|
||||
default:
|
||||
return `${activity.action} ${activity.entityType.toLowerCase()}`
|
||||
}
|
||||
}
|
||||
|
||||
export function ActivityItem({ activity }: ActivityItemProps) {
|
||||
const icon = ACTION_ICONS[activity.action] || <Edit2 className="w-4 h-4" />
|
||||
const entityIcon = ENTITY_ICONS[activity.entityType] || <FileText className="w-5 h-5" />
|
||||
const colorClass = ACTION_COLORS[activity.action] || 'bg-secondary-100 text-secondary-600'
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
|
||||
{entityIcon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-secondary-900">{getActivityDescription(activity)}</p>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">
|
||||
{activity.user.name} • {formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
src/components/appointments/PrepChecklist.tsx
Normal file
164
src/components/appointments/PrepChecklist.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Check, Square } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
import { Card, showToast } from '@/components/ui'
|
||||
import {
|
||||
DEFAULT_PREP_ITEMS,
|
||||
CATEGORY_LABELS,
|
||||
groupItemsByCategory,
|
||||
type PrepItem,
|
||||
} from '@/lib/appointments/prep-generator'
|
||||
|
||||
interface ChecklistState {
|
||||
[itemId: string]: boolean
|
||||
}
|
||||
|
||||
interface PrepChecklistProps {
|
||||
workspaceId: string
|
||||
appointmentId: string
|
||||
}
|
||||
|
||||
export function PrepChecklist({ workspaceId, appointmentId }: PrepChecklistProps) {
|
||||
const [checkedItems, setCheckedItems] = useState<ChecklistState>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [customItems, setCustomItems] = useState<PrepItem[]>([])
|
||||
|
||||
// Load checklist state from server
|
||||
useEffect(() => {
|
||||
async function loadChecklist() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCheckedItems(data.checkedItems || {})
|
||||
setCustomItems(data.customItems || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load checklist:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadChecklist()
|
||||
}, [workspaceId, appointmentId])
|
||||
|
||||
const handleToggle = async (itemId: string) => {
|
||||
const newChecked = !checkedItems[itemId]
|
||||
const newState = { ...checkedItems, [itemId]: newChecked }
|
||||
setCheckedItems(newState)
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
await fetch(
|
||||
`/api/workspaces/${workspaceId}/appointments/${appointmentId}/checklist`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ checkedItems: newState }),
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save checklist:', err)
|
||||
showToast('Failed to save', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const allItems = [...DEFAULT_PREP_ITEMS, ...customItems]
|
||||
const groupedItems = groupItemsByCategory(allItems)
|
||||
const categories = ['documents', 'health', 'comfort', 'questions']
|
||||
|
||||
// Calculate progress
|
||||
const totalItems = allItems.length
|
||||
const checkedCount = Object.values(checkedItems).filter(Boolean).length
|
||||
const progressPercent = totalItems > 0 ? Math.round((checkedCount / totalItems) * 100) : 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-secondary-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress bar */}
|
||||
<div className="bg-secondary-100 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-secondary-700">Progress</span>
|
||||
<span className="text-sm font-semibold text-primary-600">
|
||||
{checkedCount} / {totalItems} items
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-500 h-3 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progressPercent === 100 && (
|
||||
<p className="text-sm text-green-600 font-medium mt-2 text-center">
|
||||
All set for your appointment!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checklist items grouped by category */}
|
||||
{categories.map((category) => {
|
||||
const items = groupedItems[category] || []
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card key={category}>
|
||||
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
{CATEGORY_LABELS[category]}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleToggle(item.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 p-3 rounded-lg transition-colors text-left',
|
||||
checkedItems[item.id]
|
||||
? 'bg-green-50 hover:bg-green-100'
|
||||
: 'bg-secondary-50 hover:bg-secondary-100'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-6 h-6 rounded flex items-center justify-center flex-shrink-0',
|
||||
checkedItems[item.id]
|
||||
? 'bg-green-500 text-white'
|
||||
: 'border-2 border-secondary-300'
|
||||
)}
|
||||
>
|
||||
{checkedItems[item.id] && <Check className="w-4 h-4" />}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-1',
|
||||
checkedItems[item.id]
|
||||
? 'text-secondary-500 line-through'
|
||||
: 'text-secondary-900'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/components/calendar/CalendarDayCell.tsx
Normal file
71
src/components/calendar/CalendarDayCell.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { format, isToday, isSameMonth, isSameDay } from 'date-fns'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
}
|
||||
|
||||
interface CalendarDayCellProps {
|
||||
date: Date
|
||||
currentMonth: Date
|
||||
appointments: Appointment[]
|
||||
selectedDate?: Date
|
||||
onSelect: (date: Date) => void
|
||||
}
|
||||
|
||||
export function CalendarDayCell({
|
||||
date,
|
||||
currentMonth,
|
||||
appointments,
|
||||
selectedDate,
|
||||
onSelect,
|
||||
}: CalendarDayCellProps) {
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth)
|
||||
const isSelected = selectedDate && isSameDay(date, selectedDate)
|
||||
const dayAppointments = appointments.filter((a) =>
|
||||
isSameDay(new Date(a.datetime), date)
|
||||
)
|
||||
const hasAppointments = dayAppointments.length > 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(date)}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-start p-1 min-h-[60px] rounded-lg transition-colors',
|
||||
isCurrentMonth ? 'text-secondary-900' : 'text-secondary-300',
|
||||
isToday(date) && 'bg-primary-50 ring-1 ring-primary-200',
|
||||
isSelected && 'bg-primary-100 ring-2 ring-primary-500',
|
||||
!isSelected && isCurrentMonth && 'hover:bg-secondary-50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full',
|
||||
isToday(date) && 'bg-primary-500 text-white'
|
||||
)}
|
||||
>
|
||||
{format(date, 'd')}
|
||||
</span>
|
||||
|
||||
{hasAppointments && (
|
||||
<div className="flex flex-wrap gap-0.5 mt-1 justify-center">
|
||||
{dayAppointments.slice(0, 3).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary-500"
|
||||
/>
|
||||
))}
|
||||
{dayAppointments.length > 3 && (
|
||||
<span className="text-[10px] text-primary-600 font-medium">
|
||||
+{dayAppointments.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
170
src/components/calendar/CalendarMonth.tsx
Normal file
170
src/components/calendar/CalendarMonth.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameDay,
|
||||
} from 'date-fns'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
import { CalendarDayCell } from './CalendarDayCell'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
}
|
||||
|
||||
interface CalendarMonthProps {
|
||||
appointments: Appointment[]
|
||||
selectedDate: Date
|
||||
onDateSelect: (date: Date) => void
|
||||
onMonthChange: (date: Date) => void
|
||||
currentMonth: Date
|
||||
}
|
||||
|
||||
export function CalendarMonth({
|
||||
appointments,
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
onMonthChange,
|
||||
currentMonth,
|
||||
}: CalendarMonthProps) {
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const calendarStart = startOfWeek(monthStart)
|
||||
const calendarEnd = endOfWeek(monthEnd)
|
||||
|
||||
// Generate all days in the calendar view
|
||||
const days: Date[] = []
|
||||
let day = calendarStart
|
||||
while (day <= calendarEnd) {
|
||||
days.push(day)
|
||||
day = addDays(day, 1)
|
||||
}
|
||||
|
||||
// Group into weeks
|
||||
const weeks: Date[][] = []
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
weeks.push(days.slice(i, i + 7))
|
||||
}
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
onMonthChange(subMonths(currentMonth, 1))
|
||||
}
|
||||
|
||||
const handleNextMonth = () => {
|
||||
onMonthChange(addMonths(currentMonth, 1))
|
||||
}
|
||||
|
||||
const handleToday = () => {
|
||||
const today = new Date()
|
||||
onMonthChange(today)
|
||||
onDateSelect(today)
|
||||
}
|
||||
|
||||
// Get appointments for selected date
|
||||
const selectedDateAppointments = appointments
|
||||
.filter((a) => isSameDay(new Date(a.datetime), selectedDate))
|
||||
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-secondary-600" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleToday}
|
||||
className="text-sm text-primary-600 font-medium hover:underline"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((dayName) => (
|
||||
<div
|
||||
key={dayName}
|
||||
className="text-center text-xs font-medium text-secondary-500 py-2"
|
||||
>
|
||||
{dayName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{weeks.map((week, weekIdx) =>
|
||||
week.map((date, dayIdx) => (
|
||||
<CalendarDayCell
|
||||
key={`${weekIdx}-${dayIdx}`}
|
||||
date={date}
|
||||
currentMonth={currentMonth}
|
||||
appointments={appointments}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={onDateSelect}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected date appointments */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<h3 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
{format(selectedDate, 'EEEE, MMMM d')}
|
||||
</h3>
|
||||
|
||||
{selectedDateAppointments.length === 0 ? (
|
||||
<p className="text-secondary-500 text-sm">No appointments</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedDateAppointments.map((appt) => (
|
||||
<div
|
||||
key={appt.id}
|
||||
className="flex items-start gap-3 p-3 bg-secondary-50 rounded-lg"
|
||||
>
|
||||
<div className="text-sm font-medium text-primary-600 whitespace-nowrap">
|
||||
{format(new Date(appt.datetime), 'h:mm a')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-secondary-900">{appt.title}</p>
|
||||
{appt.location && (
|
||||
<p className="text-sm text-secondary-500">{appt.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
src/components/emergency/EmergencyCard.tsx
Normal file
178
src/components/emergency/EmergencyCard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { Phone, User, Droplets, AlertTriangle, Activity, Stethoscope } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface EmergencyInfo {
|
||||
patientName: string | null
|
||||
patientDOB: string | null
|
||||
bloodType: string | null
|
||||
allergies: string | null
|
||||
medicalConditions: string | null
|
||||
primaryPhysician: string | null
|
||||
physicianPhone: string | null
|
||||
clinicPhone: string | null
|
||||
emergencyPhone: string | null
|
||||
}
|
||||
|
||||
interface EmergencyCardProps {
|
||||
info: EmergencyInfo
|
||||
medications?: { name: string; instructions: string | null }[]
|
||||
variant?: 'full' | 'compact'
|
||||
}
|
||||
|
||||
export function EmergencyCard({ info, medications, variant = 'full' }: EmergencyCardProps) {
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null
|
||||
try {
|
||||
return format(new Date(dateStr), 'MMMM d, yyyy')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const hasEmergencyInfo = info.patientName || info.bloodType || info.allergies ||
|
||||
info.medicalConditions || info.primaryPhysician
|
||||
|
||||
if (!hasEmergencyInfo && variant === 'compact') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-red-600 text-white px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
<h2 className="text-xl font-bold">Emergency Information</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Patient Info */}
|
||||
{info.patientName && (
|
||||
<div className="flex items-start gap-3">
|
||||
<User className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Patient Name</p>
|
||||
<p className="text-lg font-bold text-secondary-900">{info.patientName}</p>
|
||||
{info.patientDOB && (
|
||||
<p className="text-sm text-secondary-600">DOB: {formatDate(info.patientDOB)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blood Type */}
|
||||
{info.bloodType && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Droplets className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Blood Type</p>
|
||||
<p className="text-2xl font-bold text-red-600">{info.bloodType}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allergies - High visibility */}
|
||||
{info.allergies && (
|
||||
<div className="bg-red-100 border border-red-300 rounded-lg p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-bold uppercase">Allergies</p>
|
||||
<p className="text-secondary-900 font-medium mt-1">{info.allergies}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Medical Conditions */}
|
||||
{info.medicalConditions && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Medical Conditions</p>
|
||||
<p className="text-secondary-900">{info.medicalConditions}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Medications */}
|
||||
{variant === 'full' && medications && medications.length > 0 && (
|
||||
<div className="border-t border-red-200 pt-4">
|
||||
<p className="text-sm text-red-700 font-bold mb-2">Current Medications</p>
|
||||
<ul className="space-y-1">
|
||||
{medications.map((med, i) => (
|
||||
<li key={i} className="text-secondary-900">
|
||||
<span className="font-medium">{med.name}</span>
|
||||
{med.instructions && (
|
||||
<span className="text-secondary-600"> - {med.instructions}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Doctor Info */}
|
||||
{info.primaryPhysician && (
|
||||
<div className="border-t border-red-200 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Stethoscope className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-red-700 font-medium">Primary Physician</p>
|
||||
<p className="text-secondary-900 font-medium">{info.primaryPhysician}</p>
|
||||
{info.physicianPhone && (
|
||||
<a
|
||||
href={`tel:${info.physicianPhone}`}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
{info.physicianPhone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emergency Contacts */}
|
||||
{(info.clinicPhone || info.emergencyPhone) && (
|
||||
<div className="border-t border-red-200 pt-4 space-y-3">
|
||||
<p className="text-sm text-red-700 font-bold">Emergency Contacts</p>
|
||||
|
||||
{info.clinicPhone && (
|
||||
<a
|
||||
href={`tel:${info.clinicPhone}`}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">Call Clinic</p>
|
||||
<p className="text-sm text-secondary-600">{info.clinicPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{info.emergencyPhone && (
|
||||
<a
|
||||
href={`tel:${info.emergencyPhone}`}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-lg border border-red-200 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-600 flex items-center justify-center">
|
||||
<Phone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">Emergency Contact</p>
|
||||
<p className="text-sm text-secondary-600">{info.emergencyPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { clsx } from 'clsx'
|
||||
import { Home, Calendar, Pill, FileText, MoreHorizontal } from 'lucide-react'
|
||||
import { Home, Calendar, Pill, Activity, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/today', label: 'Today', icon: Home },
|
||||
{ href: '/appointments', label: 'Appointments', icon: Calendar },
|
||||
{ href: '/appointments', label: 'Appts', icon: Calendar },
|
||||
{ href: '/meds', label: 'Meds', icon: Pill },
|
||||
{ href: '/notes', label: 'Notes', icon: FileText },
|
||||
{ href: '/symptoms', label: 'Symptoms', icon: Activity },
|
||||
{ href: '/settings', label: 'More', icon: MoreHorizontal },
|
||||
]
|
||||
|
||||
|
||||
50
src/components/medications/RefillAlert.tsx
Normal file
50
src/components/medications/RefillAlert.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RefillAlertProps {
|
||||
medications: {
|
||||
id: string
|
||||
name: string
|
||||
pillCount: number | null
|
||||
refillThreshold: number | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export function RefillAlert({ medications }: RefillAlertProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const lowMeds = medications.filter(
|
||||
m => m.pillCount !== null && m.refillThreshold !== null && m.pillCount <= m.refillThreshold
|
||||
)
|
||||
|
||||
if (lowMeds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-orange-800">
|
||||
{lowMeds.length === 1 ? 'Medication running low' : `${lowMeds.length} medications running low`}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{lowMeds.map(med => (
|
||||
<li key={med.id}>
|
||||
<button
|
||||
onClick={() => router.push(`/meds/${med.id}`)}
|
||||
className="text-sm text-orange-700 hover:text-orange-900 hover:underline"
|
||||
>
|
||||
{med.name} - {med.pillCount} pills left
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
src/components/medications/RefillTracker.tsx
Normal file
147
src/components/medications/RefillTracker.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Pill, Plus, Minus, RefreshCw } from 'lucide-react'
|
||||
import { Button, Input, Modal, showToast } from '@/components/ui'
|
||||
|
||||
interface RefillTrackerProps {
|
||||
medicationId: string
|
||||
workspaceId: string
|
||||
medicationName: string
|
||||
pillCount: number | null
|
||||
pillsPerDose: number | null
|
||||
refillThreshold: number | null
|
||||
onRefill?: () => void
|
||||
}
|
||||
|
||||
export function RefillTracker({
|
||||
medicationId,
|
||||
workspaceId,
|
||||
medicationName,
|
||||
pillCount,
|
||||
pillsPerDose = 1,
|
||||
refillThreshold = 7,
|
||||
onRefill,
|
||||
}: RefillTrackerProps) {
|
||||
const [showRefillModal, setShowRefillModal] = useState(false)
|
||||
const [refillAmount, setRefillAmount] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isLow = pillCount !== null && refillThreshold !== null && pillCount <= refillThreshold
|
||||
const dosesRemaining = pillCount !== null && pillsPerDose !== null
|
||||
? Math.floor(pillCount / pillsPerDose)
|
||||
: null
|
||||
|
||||
const handleRefill = async () => {
|
||||
const amount = parseInt(refillAmount, 10)
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
showToast('Please enter a valid amount', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${workspaceId}/medications/${medicationId}/refill`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to refill')
|
||||
|
||||
showToast(`Added ${amount} pills to ${medicationName}`, 'success')
|
||||
setShowRefillModal(false)
|
||||
setRefillAmount('')
|
||||
onRefill?.()
|
||||
} catch {
|
||||
showToast('Failed to record refill', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (pillCount === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`rounded-lg p-4 ${isLow ? 'bg-orange-50 border border-orange-200' : 'bg-secondary-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isLow ? 'bg-orange-100' : 'bg-secondary-100'}`}>
|
||||
<Pill className={`w-5 h-5 ${isLow ? 'text-orange-600' : 'text-secondary-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-secondary-900">
|
||||
{pillCount} pills remaining
|
||||
</p>
|
||||
{dosesRemaining !== null && (
|
||||
<p className={`text-sm ${isLow ? 'text-orange-600 font-medium' : 'text-secondary-500'}`}>
|
||||
{dosesRemaining} doses left
|
||||
{isLow && ' • Refill soon'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isLow ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setShowRefillModal(true)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Refill
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showRefillModal}
|
||||
onClose={() => setShowRefillModal(false)}
|
||||
title={`Refill ${medicationName}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Enter the number of pills you're adding.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefillAmount(String(Math.max(0, (parseInt(refillAmount) || 0) - 10)))}
|
||||
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
|
||||
>
|
||||
<Minus className="w-5 h-5" />
|
||||
</button>
|
||||
<Input
|
||||
type="number"
|
||||
value={refillAmount}
|
||||
onChange={(e) => setRefillAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="text-center text-2xl font-bold"
|
||||
min={0}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefillAmount(String((parseInt(refillAmount) || 0) + 10))}
|
||||
className="w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center hover:bg-secondary-200 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 text-center">
|
||||
Current: {pillCount} pills
|
||||
{refillAmount && parseInt(refillAmount) > 0 && (
|
||||
<> → After refill: {pillCount + parseInt(refillAmount)} pills</>
|
||||
)}
|
||||
</p>
|
||||
<Button onClick={handleRefill} fullWidth loading={saving}>
|
||||
Record Refill
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
227
src/components/notifications/NotificationPermission.tsx
Normal file
227
src/components/notifications/NotificationPermission.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Bell, BellOff, CheckCircle, AlertCircle, Loader } from 'lucide-react'
|
||||
|
||||
import { Button, showToast } from '@/components/ui'
|
||||
|
||||
interface NotificationPermissionProps {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
type PermissionState = 'unsupported' | 'denied' | 'granted' | 'default' | 'loading'
|
||||
|
||||
export function NotificationPermission({ workspaceId }: NotificationPermissionProps) {
|
||||
const [permission, setPermission] = useState<PermissionState>('loading')
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkPermission()
|
||||
}, [])
|
||||
|
||||
async function checkPermission() {
|
||||
if (!('Notification' in window)) {
|
||||
setPermission('unsupported')
|
||||
return
|
||||
}
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
setPermission('unsupported')
|
||||
return
|
||||
}
|
||||
|
||||
const perm = Notification.permission as PermissionState
|
||||
setPermission(perm)
|
||||
|
||||
if (perm === 'granted') {
|
||||
// Check if already subscribed
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
} catch (err) {
|
||||
console.error('Failed to check subscription:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Request notification permission
|
||||
const perm = await Notification.requestPermission()
|
||||
setPermission(perm as PermissionState)
|
||||
|
||||
if (perm !== 'granted') {
|
||||
showToast('Permission denied', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// Get VAPID public key
|
||||
const keyResponse = await fetch('/api/notifications/subscribe')
|
||||
if (!keyResponse.ok) {
|
||||
throw new Error('Push notifications not available')
|
||||
}
|
||||
const { publicKey } = await keyResponse.json()
|
||||
|
||||
// Register service worker if not already registered
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
|
||||
// Subscribe to push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsSubscribed(true)
|
||||
showToast('Notifications enabled!', 'success')
|
||||
} catch (err: any) {
|
||||
console.error('Enable notifications error:', err)
|
||||
showToast(err.message || 'Failed to enable notifications', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
|
||||
// Remove from server
|
||||
await fetch(
|
||||
`/api/notifications/subscribe?endpoint=${encodeURIComponent(subscription.endpoint)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
setIsSubscribed(false)
|
||||
showToast('Notifications disabled', 'info')
|
||||
} catch (err) {
|
||||
console.error('Disable notifications error:', err)
|
||||
showToast('Failed to disable notifications', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
|
||||
<Loader className="w-5 h-5 animate-spin text-secondary-500" />
|
||||
<span className="text-secondary-600">Checking notification status...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-yellow-50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800">Not supported</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Push notifications are not supported in this browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
|
||||
<BellOff className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="font-medium text-red-800">Blocked</p>
|
||||
<p className="text-sm text-red-700">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSubscribed) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-green-800">Notifications enabled</p>
|
||||
<p className="text-sm text-green-700">
|
||||
You'll receive medication reminders on this device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
>
|
||||
<BellOff className="w-4 h-4 mr-2" />
|
||||
Disable Notifications
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 bg-secondary-50 rounded-lg">
|
||||
<Bell className="w-5 h-5 text-secondary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-secondary-800">Enable notifications</p>
|
||||
<p className="text-sm text-secondary-600">
|
||||
Get reminders when it's time to take your medications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Enable Notifications
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to convert VAPID key
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const buffer = new ArrayBuffer(rawData.length)
|
||||
const outputArray = new Uint8Array(buffer)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray
|
||||
}
|
||||
26
src/components/notifications/ServiceWorkerRegistrar.tsx
Normal file
26
src/components/notifications/ServiceWorkerRegistrar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function ServiceWorkerRegistrar() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register service worker
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered with scope:', registration.scope)
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
88
src/components/symptoms/SymptomCard.tsx
Normal file
88
src/components/symptoms/SymptomCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
|
||||
const SYMPTOM_INFO: Record<string, { emoji: string; label: string }> = {
|
||||
FATIGUE: { emoji: '😴', label: 'Fatigue' },
|
||||
NAUSEA: { emoji: '🤢', label: 'Nausea' },
|
||||
PAIN: { emoji: '😣', label: 'Pain' },
|
||||
APPETITE: { emoji: '🍽️', label: 'Appetite' },
|
||||
SLEEP: { emoji: '😴', label: 'Sleep' },
|
||||
MOOD: { emoji: '😔', label: 'Mood' },
|
||||
CUSTOM: { emoji: '📝', label: 'Custom' },
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<number, string> = {
|
||||
1: 'bg-green-100 text-green-800 border-green-200',
|
||||
2: 'bg-lime-100 text-lime-800 border-lime-200',
|
||||
3: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
4: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
5: 'bg-red-100 text-red-800 border-red-200',
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<number, string> = {
|
||||
1: 'Minimal',
|
||||
2: 'Mild',
|
||||
3: 'Moderate',
|
||||
4: 'Severe',
|
||||
5: 'Extreme',
|
||||
}
|
||||
|
||||
interface Symptom {
|
||||
id: string
|
||||
type: string
|
||||
customName: string | null
|
||||
severity: number
|
||||
notes: string | null
|
||||
recordedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
}
|
||||
|
||||
interface SymptomCardProps {
|
||||
symptom: Symptom
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function SymptomCard({ symptom, compact = false }: SymptomCardProps) {
|
||||
const info = SYMPTOM_INFO[symptom.type] || SYMPTOM_INFO.CUSTOM
|
||||
const displayName = symptom.type === 'CUSTOM' && symptom.customName
|
||||
? symptom.customName
|
||||
: info.label
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${SEVERITY_COLORS[symptom.severity]}`}>
|
||||
<span className="text-lg">{info.emoji}</span>
|
||||
<span className="font-medium">{displayName}</span>
|
||||
<span className="text-xs opacity-75">
|
||||
{formatDistanceToNow(new Date(symptom.recordedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${SEVERITY_COLORS[symptom.severity]}`}>
|
||||
<span className="text-2xl">{info.emoji}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-secondary-900">{displayName}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${SEVERITY_COLORS[symptom.severity]}`}>
|
||||
{SEVERITY_LABELS[symptom.severity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 mt-0.5">
|
||||
{format(new Date(symptom.recordedAt), 'EEEE, MMM d \'at\' h:mm a')}
|
||||
{symptom.createdBy && ` • ${symptom.createdBy.name}`}
|
||||
</p>
|
||||
{symptom.notes && (
|
||||
<p className="text-sm text-secondary-600 mt-2">{symptom.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/components/symptoms/SymptomChart.tsx
Normal file
112
src/components/symptoms/SymptomChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { format, subDays, startOfDay, isSameDay } from 'date-fns'
|
||||
|
||||
interface Symptom {
|
||||
id: string
|
||||
type: string
|
||||
severity: number
|
||||
recordedAt: string
|
||||
}
|
||||
|
||||
interface SymptomChartProps {
|
||||
symptoms: Symptom[]
|
||||
days?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS = [
|
||||
'#22c55e', // 1 - green
|
||||
'#84cc16', // 2 - lime
|
||||
'#eab308', // 3 - yellow
|
||||
'#f97316', // 4 - orange
|
||||
'#ef4444', // 5 - red
|
||||
]
|
||||
|
||||
export function SymptomChart({ symptoms, days = 7, type }: SymptomChartProps) {
|
||||
// Generate date range
|
||||
const today = startOfDay(new Date())
|
||||
const dates = Array.from({ length: days }, (_, i) => subDays(today, days - 1 - i))
|
||||
|
||||
// Filter by type if specified
|
||||
const filtered = type
|
||||
? symptoms.filter(s => s.type === type)
|
||||
: symptoms
|
||||
|
||||
// Group symptoms by date and calculate average severity
|
||||
const dataByDate = dates.map(date => {
|
||||
const daySymptoms = filtered.filter(s =>
|
||||
isSameDay(new Date(s.recordedAt), date)
|
||||
)
|
||||
const avgSeverity = daySymptoms.length > 0
|
||||
? daySymptoms.reduce((sum, s) => sum + s.severity, 0) / daySymptoms.length
|
||||
: 0
|
||||
return {
|
||||
date,
|
||||
avgSeverity,
|
||||
count: daySymptoms.length,
|
||||
}
|
||||
})
|
||||
|
||||
const maxHeight = 100
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-secondary-500">
|
||||
No symptom data to display
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Chart */}
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{dataByDate.map((day, i) => {
|
||||
const height = day.avgSeverity > 0 ? (day.avgSeverity / 5) * maxHeight : 4
|
||||
const colorIndex = day.avgSeverity > 0 ? Math.round(day.avgSeverity) - 1 : 0
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full rounded-t transition-all"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
backgroundColor: day.avgSeverity > 0 ? SEVERITY_COLORS[colorIndex] : '#e5e7eb',
|
||||
minHeight: day.avgSeverity > 0 ? '8px' : '4px',
|
||||
}}
|
||||
title={day.avgSeverity > 0 ? `Avg: ${day.avgSeverity.toFixed(1)}` : 'No data'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Date labels */}
|
||||
<div className="flex gap-1">
|
||||
{dataByDate.map((day, i) => (
|
||||
<div key={i} className="flex-1 text-center">
|
||||
<span className="text-xs text-secondary-500">
|
||||
{format(day.date, 'EEE')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex justify-center gap-3 text-xs text-secondary-500 pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[0] }} />
|
||||
<span>Minimal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[2] }} />
|
||||
<span>Moderate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: SEVERITY_COLORS[4] }} />
|
||||
<span>Extreme</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
src/components/symptoms/SymptomQuickLog.tsx
Normal file
145
src/components/symptoms/SymptomQuickLog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, showToast } from '@/components/ui'
|
||||
|
||||
const SYMPTOM_TYPES = [
|
||||
{ type: 'FATIGUE', emoji: '😴', label: 'Fatigue' },
|
||||
{ type: 'NAUSEA', emoji: '🤢', label: 'Nausea' },
|
||||
{ type: 'PAIN', emoji: '😣', label: 'Pain' },
|
||||
{ type: 'APPETITE', emoji: '🍽️', label: 'Appetite' },
|
||||
{ type: 'SLEEP', emoji: '😴', label: 'Sleep' },
|
||||
{ type: 'MOOD', emoji: '😔', label: 'Mood' },
|
||||
]
|
||||
|
||||
const SEVERITY_LABELS = [
|
||||
{ value: 1, label: 'Minimal', color: 'bg-green-500' },
|
||||
{ value: 2, label: 'Mild', color: 'bg-lime-500' },
|
||||
{ value: 3, label: 'Moderate', color: 'bg-yellow-500' },
|
||||
{ value: 4, label: 'Severe', color: 'bg-orange-500' },
|
||||
{ value: 5, label: 'Extreme', color: 'bg-red-500' },
|
||||
]
|
||||
|
||||
interface SymptomQuickLogProps {
|
||||
workspaceId: string
|
||||
onLogged?: () => void
|
||||
}
|
||||
|
||||
export function SymptomQuickLog({ workspaceId, onLogged }: SymptomQuickLogProps) {
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const [severity, setSeverity] = useState(3)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedType) {
|
||||
showToast('Please select a symptom', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/symptoms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: selectedType,
|
||||
severity,
|
||||
notes: notes.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to log symptom')
|
||||
|
||||
showToast('Symptom logged', 'success')
|
||||
setSelectedType(null)
|
||||
setSeverity(3)
|
||||
setNotes('')
|
||||
onLogged?.()
|
||||
} catch {
|
||||
showToast('Failed to log symptom', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Symptom Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
How are you feeling?
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SYMPTOM_TYPES.map((symptom) => (
|
||||
<button
|
||||
key={symptom.type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(symptom.type)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all ${
|
||||
selectedType === symptom.type
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-border hover:border-secondary-300 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{symptom.emoji}</span>
|
||||
<span className="text-xs font-medium text-secondary-700">{symptom.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity Slider */}
|
||||
{selectedType && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Severity: <span className="font-bold">{SEVERITY_LABELS[severity - 1].label}</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{SEVERITY_LABELS.map((level) => (
|
||||
<button
|
||||
key={level.value}
|
||||
type="button"
|
||||
onClick={() => setSeverity(level.value)}
|
||||
className={`flex-1 h-10 rounded-lg transition-all ${level.color} ${
|
||||
severity === level.value
|
||||
? 'ring-2 ring-offset-2 ring-secondary-900 scale-110'
|
||||
: 'opacity-40 hover:opacity-70'
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">{level.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-secondary-500">
|
||||
<span>Minimal</span>
|
||||
<span>Extreme</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedType && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any additional details..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
{selectedType && (
|
||||
<Button onClick={handleSubmit} fullWidth loading={saving}>
|
||||
Log Symptom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/lib/appointments/prep-generator.ts
Normal file
55
src/lib/appointments/prep-generator.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Default checklist items for appointment preparation
|
||||
export interface PrepItem {
|
||||
id: string
|
||||
label: string
|
||||
category: 'documents' | 'health' | 'comfort' | 'questions'
|
||||
}
|
||||
|
||||
export const DEFAULT_PREP_ITEMS: PrepItem[] = [
|
||||
// Documents
|
||||
{ id: 'insurance-card', label: 'Insurance card', category: 'documents' },
|
||||
{ id: 'photo-id', label: 'Photo ID', category: 'documents' },
|
||||
{ id: 'medication-list', label: 'Current medication list', category: 'documents' },
|
||||
{ id: 'test-results', label: 'Recent test results', category: 'documents' },
|
||||
{ id: 'referral', label: 'Referral letter (if needed)', category: 'documents' },
|
||||
|
||||
// Health
|
||||
{ id: 'take-meds', label: 'Take morning medications', category: 'health' },
|
||||
{ id: 'symptoms-notes', label: 'Write down recent symptoms', category: 'health' },
|
||||
{ id: 'blood-pressure', label: 'Record blood pressure', category: 'health' },
|
||||
|
||||
// Comfort
|
||||
{ id: 'snacks', label: 'Pack snacks and water', category: 'comfort' },
|
||||
{ id: 'phone-charger', label: 'Phone charger', category: 'comfort' },
|
||||
{ id: 'book-entertainment', label: 'Book or entertainment', category: 'comfort' },
|
||||
{ id: 'comfortable-clothes', label: 'Comfortable clothing', category: 'comfort' },
|
||||
|
||||
// Questions
|
||||
{ id: 'questions-list', label: 'Prepare questions for doctor', category: 'questions' },
|
||||
{ id: 'side-effects', label: 'Note medication side effects', category: 'questions' },
|
||||
{ id: 'concerns', label: 'List any concerns', category: 'questions' },
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
documents: 'Documents to Bring',
|
||||
health: 'Health Preparation',
|
||||
comfort: 'Comfort Items',
|
||||
questions: 'Questions & Notes',
|
||||
}
|
||||
|
||||
export function getDefaultChecklistItems(): PrepItem[] {
|
||||
return [...DEFAULT_PREP_ITEMS]
|
||||
}
|
||||
|
||||
export function groupItemsByCategory(items: PrepItem[]): Record<string, PrepItem[]> {
|
||||
return items.reduce(
|
||||
(acc, item) => {
|
||||
if (!acc[item.category]) {
|
||||
acc[item.category] = []
|
||||
}
|
||||
acc[item.category].push(item)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, PrepItem[]>
|
||||
)
|
||||
}
|
||||
175
src/lib/calendar/ical-generator.ts
Normal file
175
src/lib/calendar/ical-generator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { format, addHours } from 'date-fns'
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: Date | string
|
||||
location: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
id: string
|
||||
name: string
|
||||
scheduleType: string
|
||||
scheduleData: {
|
||||
type: string
|
||||
times?: string[]
|
||||
hours?: number
|
||||
startTime?: string
|
||||
time?: string
|
||||
days?: number[]
|
||||
}
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an iCalendar format string for appointments
|
||||
*/
|
||||
export function generateICalendar(
|
||||
appointments: Appointment[],
|
||||
workspaceName: string
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//NextStep//Health Management//EN',
|
||||
`X-WR-CALNAME:${escapeICalText(workspaceName)}`,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
]
|
||||
|
||||
for (const appt of appointments) {
|
||||
const startDate = new Date(appt.datetime)
|
||||
const endDate = addHours(startDate, 1) // Default 1 hour duration
|
||||
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:${appt.id}@nextstep`)
|
||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
||||
lines.push(`SUMMARY:${escapeICalText(appt.title)}`)
|
||||
|
||||
if (appt.location) {
|
||||
lines.push(`LOCATION:${escapeICalText(appt.location)}`)
|
||||
}
|
||||
|
||||
if (appt.notes) {
|
||||
lines.push(`DESCRIPTION:${escapeICalText(appt.notes)}`)
|
||||
}
|
||||
|
||||
// Add reminder 1 day before
|
||||
lines.push('BEGIN:VALARM')
|
||||
lines.push('TRIGGER:-P1D')
|
||||
lines.push('ACTION:DISPLAY')
|
||||
lines.push(`DESCRIPTION:Reminder: ${escapeICalText(appt.title)}`)
|
||||
lines.push('END:VALARM')
|
||||
|
||||
// Add reminder 2 hours before
|
||||
lines.push('BEGIN:VALARM')
|
||||
lines.push('TRIGGER:-PT2H')
|
||||
lines.push('ACTION:DISPLAY')
|
||||
lines.push(`DESCRIPTION:${escapeICalText(appt.title)} in 2 hours`)
|
||||
lines.push('END:VALARM')
|
||||
|
||||
lines.push('END:VEVENT')
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate medication schedule events for the current day
|
||||
* This is useful for daily reminders but not recommended for calendar sync
|
||||
*/
|
||||
export function generateMedicationEvents(
|
||||
medications: Medication[],
|
||||
workspaceName: string,
|
||||
targetDate: Date = new Date()
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//NextStep//Medications//EN',
|
||||
`X-WR-CALNAME:${escapeICalText(workspaceName)} - Medications`,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
]
|
||||
|
||||
const dateStr = format(targetDate, 'yyyy-MM-dd')
|
||||
|
||||
for (const med of medications) {
|
||||
if (!med.active) continue
|
||||
|
||||
const times = getMedicationTimes(med)
|
||||
|
||||
for (const time of times) {
|
||||
if (time === 'As needed') continue
|
||||
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const startDate = new Date(targetDate)
|
||||
startDate.setHours(hours, minutes, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setMinutes(endDate.getMinutes() + 15)
|
||||
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:med-${med.id}-${dateStr}-${time}@nextstep`)
|
||||
lines.push(`DTSTAMP:${formatICalDate(new Date())}`)
|
||||
lines.push(`DTSTART:${formatICalDate(startDate)}`)
|
||||
lines.push(`DTEND:${formatICalDate(endDate)}`)
|
||||
lines.push(`SUMMARY:Take ${escapeICalText(med.name)}`)
|
||||
lines.push('CATEGORIES:MEDICATION')
|
||||
|
||||
lines.push('BEGIN:VALARM')
|
||||
lines.push('TRIGGER:PT0M')
|
||||
lines.push('ACTION:DISPLAY')
|
||||
lines.push(`DESCRIPTION:Time to take ${escapeICalText(med.name)}`)
|
||||
lines.push('END:VALARM')
|
||||
|
||||
lines.push('END:VEVENT')
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
function getMedicationTimes(med: Medication): string[] {
|
||||
const { scheduleType, scheduleData } = med
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return scheduleData.times || []
|
||||
case 'INTERVAL':
|
||||
const times: string[] = []
|
||||
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
|
||||
const hours = scheduleData.hours || 4
|
||||
for (let h = startHour; h < 24; h += hours) {
|
||||
const hourStr = h.toString().padStart(2, '0')
|
||||
times.push(`${hourStr}:00`)
|
||||
}
|
||||
return times
|
||||
case 'WEEKDAYS':
|
||||
return scheduleData.time ? [scheduleData.time] : []
|
||||
case 'PRN':
|
||||
return ['As needed']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatICalDate(date: Date): string {
|
||||
// Format: YYYYMMDDTHHMMSSZ
|
||||
return format(date, "yyyyMMdd'T'HHmmss'Z'")
|
||||
}
|
||||
|
||||
function escapeICalText(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n')
|
||||
}
|
||||
266
src/lib/export/pdf-generator.ts
Normal file
266
src/lib/export/pdf-generator.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import PDFDocument from 'pdfkit'
|
||||
import { format, subDays } from 'date-fns'
|
||||
|
||||
interface PatientInfo {
|
||||
name: string
|
||||
patientName?: string | null
|
||||
patientDOB?: Date | string | null
|
||||
bloodType?: string | null
|
||||
allergies?: string | null
|
||||
medicalConditions?: string | null
|
||||
primaryPhysician?: string | null
|
||||
physicianPhone?: string | null
|
||||
clinicPhone?: string | null
|
||||
emergencyPhone?: string | null
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string | null
|
||||
scheduleType: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
datetime: Date | string
|
||||
location: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
interface Symptom {
|
||||
id: string
|
||||
type: string
|
||||
customName: string | null
|
||||
severity: number
|
||||
notes: string | null
|
||||
recordedAt: Date | string
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
patient: PatientInfo
|
||||
medications: Medication[]
|
||||
appointments: Appointment[]
|
||||
symptoms: Symptom[]
|
||||
generatedAt: Date
|
||||
}
|
||||
|
||||
const SYMPTOM_LABELS: Record<string, string> = {
|
||||
FATIGUE: 'Fatigue',
|
||||
NAUSEA: 'Nausea',
|
||||
PAIN: 'Pain',
|
||||
APPETITE: 'Appetite Changes',
|
||||
SLEEP: 'Sleep Issues',
|
||||
MOOD: 'Mood Changes',
|
||||
CUSTOM: 'Other',
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS = ['Minimal', 'Mild', 'Moderate', 'Severe', 'Extreme']
|
||||
|
||||
export function generateMedicalSummaryPDF(data: SummaryData): PDFKit.PDFDocument {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margins: { top: 50, bottom: 50, left: 50, right: 50 },
|
||||
info: {
|
||||
Title: `Medical Summary - ${data.patient.patientName || data.patient.name}`,
|
||||
Author: 'NextStep Health Management',
|
||||
Subject: 'Medical Summary',
|
||||
CreationDate: data.generatedAt,
|
||||
},
|
||||
})
|
||||
|
||||
const pageWidth = doc.page.width - 100 // Minus margins
|
||||
|
||||
// Header
|
||||
doc
|
||||
.fontSize(24)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Medical Summary', { align: 'center' })
|
||||
.moveDown(0.5)
|
||||
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica')
|
||||
.fillColor('#666666')
|
||||
.text(`Generated: ${format(data.generatedAt, 'MMMM d, yyyy h:mm a')}`, { align: 'center' })
|
||||
.moveDown(1.5)
|
||||
.fillColor('#000000')
|
||||
|
||||
// Patient Information Section
|
||||
addSectionHeader(doc, 'Patient Information')
|
||||
|
||||
const patientInfo = [
|
||||
['Name', data.patient.patientName || data.patient.name || 'Not specified'],
|
||||
['Date of Birth', data.patient.patientDOB ? format(new Date(data.patient.patientDOB), 'MMMM d, yyyy') : 'Not specified'],
|
||||
['Blood Type', data.patient.bloodType || 'Not specified'],
|
||||
['Primary Physician', data.patient.primaryPhysician || 'Not specified'],
|
||||
['Physician Phone', data.patient.physicianPhone || 'Not specified'],
|
||||
['Clinic Phone', data.patient.clinicPhone || 'Not specified'],
|
||||
['Emergency Contact', data.patient.emergencyPhone || 'Not specified'],
|
||||
]
|
||||
|
||||
for (const [label, value] of patientInfo) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`${label}: `, { continued: true })
|
||||
.font('Helvetica')
|
||||
.text(value)
|
||||
}
|
||||
|
||||
doc.moveDown(0.5)
|
||||
|
||||
// Allergies (highlighted)
|
||||
if (data.patient.allergies) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor('#DC2626')
|
||||
.text('ALLERGIES: ', { continued: true })
|
||||
.font('Helvetica')
|
||||
.text(data.patient.allergies)
|
||||
.fillColor('#000000')
|
||||
}
|
||||
|
||||
// Medical Conditions
|
||||
if (data.patient.medicalConditions) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text('Medical Conditions: ', { continued: true })
|
||||
.font('Helvetica')
|
||||
.text(data.patient.medicalConditions)
|
||||
}
|
||||
|
||||
doc.moveDown(1)
|
||||
|
||||
// Current Medications Section
|
||||
addSectionHeader(doc, `Current Medications (${data.medications.filter(m => m.active).length})`)
|
||||
|
||||
const activeMeds = data.medications.filter((m) => m.active)
|
||||
if (activeMeds.length === 0) {
|
||||
doc.fontSize(10).font('Helvetica-Oblique').text('No active medications')
|
||||
} else {
|
||||
for (const med of activeMeds) {
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`• ${med.name}`, { continued: med.instructions ? true : false })
|
||||
|
||||
if (med.instructions) {
|
||||
doc.font('Helvetica').text(` - ${med.instructions}`)
|
||||
} else {
|
||||
doc.text('')
|
||||
}
|
||||
|
||||
doc.fontSize(9).fillColor('#666666').text(` Schedule: ${formatScheduleType(med.scheduleType)}`).fillColor('#000000')
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(1)
|
||||
|
||||
// Upcoming Appointments Section
|
||||
const upcomingAppts = data.appointments
|
||||
.filter((a) => new Date(a.datetime) >= new Date())
|
||||
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
|
||||
.slice(0, 5)
|
||||
|
||||
addSectionHeader(doc, `Upcoming Appointments (${upcomingAppts.length})`)
|
||||
|
||||
if (upcomingAppts.length === 0) {
|
||||
doc.fontSize(10).font('Helvetica-Oblique').text('No upcoming appointments')
|
||||
} else {
|
||||
for (const appt of upcomingAppts) {
|
||||
const apptDate = new Date(appt.datetime)
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`• ${format(apptDate, 'EEE, MMM d')} at ${format(apptDate, 'h:mm a')}`)
|
||||
.font('Helvetica')
|
||||
.text(` ${appt.title}${appt.location ? ` - ${appt.location}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(1)
|
||||
|
||||
// Recent Symptoms Section (last 30 days)
|
||||
const thirtyDaysAgo = subDays(new Date(), 30)
|
||||
const recentSymptoms = data.symptoms
|
||||
.filter((s) => new Date(s.recordedAt) >= thirtyDaysAgo)
|
||||
.sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime())
|
||||
|
||||
// Group by type
|
||||
const symptomSummary = recentSymptoms.reduce(
|
||||
(acc, s) => {
|
||||
const key = s.type
|
||||
if (!acc[key]) {
|
||||
acc[key] = { count: 0, totalSeverity: 0, maxSeverity: 0 }
|
||||
}
|
||||
acc[key].count++
|
||||
acc[key].totalSeverity += s.severity
|
||||
acc[key].maxSeverity = Math.max(acc[key].maxSeverity, s.severity)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { count: number; totalSeverity: number; maxSeverity: number }>
|
||||
)
|
||||
|
||||
addSectionHeader(doc, 'Symptoms (Last 30 Days)')
|
||||
|
||||
if (Object.keys(symptomSummary).length === 0) {
|
||||
doc.fontSize(10).font('Helvetica-Oblique').text('No symptoms recorded in the last 30 days')
|
||||
} else {
|
||||
for (const [type, stats] of Object.entries(symptomSummary).sort((a, b) => b[1].count - a[1].count)) {
|
||||
const avgSeverity = Math.round(stats.totalSeverity / stats.count)
|
||||
doc
|
||||
.fontSize(10)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`• ${SYMPTOM_LABELS[type] || type}: `, { continued: true })
|
||||
.font('Helvetica')
|
||||
.text(`${stats.count} occurrence(s), Avg: ${SEVERITY_LABELS[avgSeverity - 1]}, Max: ${SEVERITY_LABELS[stats.maxSeverity - 1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(1.5)
|
||||
|
||||
// Footer
|
||||
doc
|
||||
.fontSize(8)
|
||||
.fillColor('#999999')
|
||||
.text('This document is for informational purposes only and does not constitute medical advice.', { align: 'center' })
|
||||
.text('Generated by NextStep Health Management', { align: 'center' })
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
function addSectionHeader(doc: PDFKit.PDFDocument, title: string) {
|
||||
doc
|
||||
.fontSize(14)
|
||||
.font('Helvetica-Bold')
|
||||
.fillColor('#1E3A8A')
|
||||
.text(title)
|
||||
.moveDown(0.3)
|
||||
.strokeColor('#1E3A8A')
|
||||
.lineWidth(1)
|
||||
.moveTo(50, doc.y)
|
||||
.lineTo(545, doc.y)
|
||||
.stroke()
|
||||
.moveDown(0.5)
|
||||
.fillColor('#000000')
|
||||
}
|
||||
|
||||
function formatScheduleType(type: string): string {
|
||||
switch (type) {
|
||||
case 'FIXED_TIMES':
|
||||
return 'Fixed times daily'
|
||||
case 'INTERVAL':
|
||||
return 'At regular intervals'
|
||||
case 'WEEKDAYS':
|
||||
return 'Specific days of the week'
|
||||
case 'PRN':
|
||||
return 'As needed'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
81
src/lib/notifications/push.ts
Normal file
81
src/lib/notifications/push.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import webpush from 'web-push'
|
||||
|
||||
// These should be set in environment variables
|
||||
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''
|
||||
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''
|
||||
const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:admin@nextstep.local'
|
||||
|
||||
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
|
||||
webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
}
|
||||
|
||||
interface PushSubscriptionData {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
title: string
|
||||
body: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
tag?: string
|
||||
data?: {
|
||||
url?: string
|
||||
medicationId?: string
|
||||
action?: string
|
||||
}
|
||||
actions?: Array<{
|
||||
action: string
|
||||
title: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: PushSubscriptionData,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
|
||||
console.warn('VAPID keys not configured, skipping push notification')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
}
|
||||
|
||||
await webpush.sendNotification(
|
||||
pushSubscription,
|
||||
JSON.stringify(payload)
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
// Subscription has expired or is no longer valid
|
||||
console.log('Push subscription expired:', subscription.endpoint)
|
||||
return false
|
||||
}
|
||||
|
||||
console.error('Push notification error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function generateVAPIDKeys(): { publicKey: string; privateKey: string } {
|
||||
const keys = webpush.generateVAPIDKeys()
|
||||
return {
|
||||
publicKey: keys.publicKey,
|
||||
privateKey: keys.privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicVAPIDKey(): string {
|
||||
return VAPID_PUBLIC_KEY
|
||||
}
|
||||
188
src/lib/notifications/scheduler.ts
Normal file
188
src/lib/notifications/scheduler.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { sendPushNotification } from './push'
|
||||
|
||||
interface MedicationSchedule {
|
||||
medicationId: string
|
||||
medicationName: string
|
||||
workspaceId: string
|
||||
times: string[] // HH:MM format
|
||||
quietHoursStart: string | null
|
||||
quietHoursEnd: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current time is within quiet hours
|
||||
*/
|
||||
function isQuietHours(
|
||||
now: Date,
|
||||
quietStart: string | null,
|
||||
quietEnd: string | null
|
||||
): boolean {
|
||||
if (!quietStart || !quietEnd) return false
|
||||
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const [startH, startM] = quietStart.split(':').map(Number)
|
||||
const [endH, endM] = quietEnd.split(':').map(Number)
|
||||
const startMinutes = startH * 60 + startM
|
||||
const endMinutes = endH * 60 + endM
|
||||
|
||||
// Handle overnight quiet hours (e.g., 22:00 to 07:00)
|
||||
if (startMinutes > endMinutes) {
|
||||
return currentMinutes >= startMinutes || currentMinutes < endMinutes
|
||||
}
|
||||
|
||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a medication dose is due at the current time
|
||||
*/
|
||||
function isDue(scheduledTime: string, now: Date, toleranceMinutes = 5): boolean {
|
||||
const [hours, minutes] = scheduledTime.split(':').map(Number)
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const schedMinutes = hours * 60 + minutes
|
||||
|
||||
// Due if within tolerance window
|
||||
return Math.abs(nowMinutes - schedMinutes) <= toleranceMinutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all medication schedules that need notifications sent
|
||||
* This should be called by a cron job or similar
|
||||
*/
|
||||
export async function getMedicationsDue(
|
||||
now: Date = new Date()
|
||||
): Promise<MedicationSchedule[]> {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
quietHoursStart: true,
|
||||
quietHoursEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const due: MedicationSchedule[] = []
|
||||
|
||||
for (const med of medications) {
|
||||
const scheduleData = med.scheduleData as any
|
||||
|
||||
// Skip if in quiet hours
|
||||
if (
|
||||
isQuietHours(
|
||||
now,
|
||||
med.workspace.quietHoursStart,
|
||||
med.workspace.quietHoursEnd
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
let times: string[] = []
|
||||
|
||||
switch (med.scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
times = scheduleData.times || []
|
||||
break
|
||||
case 'INTERVAL':
|
||||
// Generate times based on interval
|
||||
const startHour = parseInt(scheduleData.startTime?.split(':')[0] || '8')
|
||||
const hours = scheduleData.hours || 4
|
||||
for (let h = startHour; h < 24; h += hours) {
|
||||
times.push(`${h.toString().padStart(2, '0')}:00`)
|
||||
}
|
||||
break
|
||||
case 'WEEKDAYS':
|
||||
// Check if today is a scheduled day
|
||||
const todayDow = now.getDay()
|
||||
if (scheduleData.days?.includes(todayDow)) {
|
||||
times = scheduleData.time ? [scheduleData.time] : []
|
||||
}
|
||||
break
|
||||
case 'PRN':
|
||||
// PRN medications don't have scheduled times
|
||||
break
|
||||
}
|
||||
|
||||
// Check if any times are due now
|
||||
const dueNow = times.some((time) => isDue(time, now))
|
||||
if (dueNow) {
|
||||
due.push({
|
||||
medicationId: med.id,
|
||||
medicationName: med.name,
|
||||
workspaceId: med.workspaceId,
|
||||
times,
|
||||
quietHoursStart: med.workspace.quietHoursStart,
|
||||
quietHoursEnd: med.workspace.quietHoursEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return due
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications for all due medications
|
||||
*/
|
||||
export async function sendDueNotifications(
|
||||
now: Date = new Date()
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
const due = await getMedicationsDue(now)
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (const med of due) {
|
||||
// Get all push subscriptions for this workspace
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { workspaceId: med.workspaceId },
|
||||
})
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
const success = await sendPushNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
{
|
||||
title: 'Medication Reminder',
|
||||
body: `Time to take ${med.medicationName}`,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-72.png',
|
||||
tag: `med-${med.medicationId}`,
|
||||
data: {
|
||||
url: '/meds',
|
||||
medicationId: med.medicationId,
|
||||
action: 'take_dose',
|
||||
},
|
||||
actions: [
|
||||
{ action: 'take', title: 'Mark as Taken' },
|
||||
{ action: 'snooze', title: 'Snooze 15 min' },
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if (success) {
|
||||
sent++
|
||||
} else {
|
||||
// Subscription expired, remove it
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } })
|
||||
failed++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed }
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export interface LocalMedication {
|
||||
syncedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
updatedBy?: { id: string; name: string }
|
||||
// Refill tracking fields
|
||||
pillCount: number | null
|
||||
pillsPerDose: number | null
|
||||
refillThreshold: number | null
|
||||
lastRefillDate: string | null
|
||||
}
|
||||
|
||||
export interface LocalNote {
|
||||
@@ -68,13 +73,37 @@ export interface LocalWorkspace {
|
||||
largeTextMode: boolean
|
||||
role?: string
|
||||
updatedAt: string
|
||||
// Emergency info fields
|
||||
patientName: string | null
|
||||
patientDOB: string | null
|
||||
bloodType: string | null
|
||||
allergies: string | null
|
||||
medicalConditions: string | null
|
||||
primaryPhysician: string | null
|
||||
physicianPhone: string | null
|
||||
}
|
||||
|
||||
export type SymptomType = 'FATIGUE' | 'NAUSEA' | 'PAIN' | 'APPETITE' | 'SLEEP' | 'MOOD' | 'CUSTOM'
|
||||
|
||||
export interface LocalSymptom {
|
||||
id: string
|
||||
workspaceId: string
|
||||
type: SymptomType
|
||||
customName: string | null
|
||||
severity: number
|
||||
notes: string | null
|
||||
recordedAt: string
|
||||
deletedAt: string | null
|
||||
version: number
|
||||
syncedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
}
|
||||
|
||||
export interface SyncOp {
|
||||
id: string
|
||||
workspaceId: string
|
||||
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED'
|
||||
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG'
|
||||
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED' | 'UNMARK_ASKED' | 'REFILL' | 'LOG_SYMPTOM' | 'DELETE_SYMPTOM'
|
||||
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG' | 'SYMPTOM'
|
||||
entityId?: string
|
||||
data?: Record<string, unknown>
|
||||
timestamp: number
|
||||
@@ -94,12 +123,14 @@ class NextStepDB extends Dexie {
|
||||
notes!: Table<LocalNote, string>
|
||||
doseLogs!: Table<LocalDoseLog, string>
|
||||
workspaces!: Table<LocalWorkspace, string>
|
||||
symptoms!: Table<LocalSymptom, string>
|
||||
outbox!: Table<SyncOp, string>
|
||||
syncMeta!: Table<SyncMeta, string>
|
||||
|
||||
constructor() {
|
||||
super('NextStepDB')
|
||||
|
||||
// Version 1: Original schema
|
||||
this.version(1).stores({
|
||||
appointments: 'id, workspaceId, datetime, deletedAt',
|
||||
medications: 'id, workspaceId, active, deletedAt',
|
||||
@@ -109,6 +140,18 @@ class NextStepDB extends Dexie {
|
||||
outbox: 'id, workspaceId, timestamp',
|
||||
syncMeta: 'id, workspaceId',
|
||||
})
|
||||
|
||||
// Version 2: Add symptoms table, extend workspace & medication fields
|
||||
this.version(2).stores({
|
||||
appointments: 'id, workspaceId, datetime, deletedAt',
|
||||
medications: 'id, workspaceId, active, deletedAt',
|
||||
notes: 'id, workspaceId, type, deletedAt',
|
||||
doseLogs: 'id, medicationId, workspaceId, takenAt',
|
||||
workspaces: 'id',
|
||||
symptoms: 'id, workspaceId, type, recordedAt, deletedAt',
|
||||
outbox: 'id, workspaceId, timestamp',
|
||||
syncMeta: 'id, workspaceId',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@ export {
|
||||
logDose,
|
||||
undoDose,
|
||||
markQuestionAsked,
|
||||
unmarkQuestionAsked,
|
||||
} from './manager'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db, generateTempId, type SyncOp } from './db'
|
||||
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog } from './db'
|
||||
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog, LocalSymptom } from './db'
|
||||
|
||||
const SYNC_INTERVAL = 30000 // 30 seconds
|
||||
const MAX_RETRIES = 3
|
||||
@@ -70,12 +70,20 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
|
||||
const data = await response.json()
|
||||
|
||||
// Update local database
|
||||
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.workspaces, db.syncMeta], async () => {
|
||||
// Update workspace
|
||||
await db.transaction('rw', [db.appointments, db.medications, db.notes, db.doseLogs, db.symptoms, db.workspaces, db.syncMeta], async () => {
|
||||
// Update workspace (including emergency info fields)
|
||||
if (data.workspace) {
|
||||
await db.workspaces.put({
|
||||
...data.workspace,
|
||||
updatedAt: data.workspace.updatedAt || new Date().toISOString(),
|
||||
// Ensure emergency fields are properly set (even if null)
|
||||
patientName: data.workspace.patientName || null,
|
||||
patientDOB: data.workspace.patientDOB || null,
|
||||
bloodType: data.workspace.bloodType || null,
|
||||
allergies: data.workspace.allergies || null,
|
||||
medicalConditions: data.workspace.medicalConditions || null,
|
||||
primaryPhysician: data.workspace.primaryPhysician || null,
|
||||
physicianPhone: data.workspace.physicianPhone || null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +140,18 @@ export async function pullChanges(workspaceId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Update symptoms
|
||||
for (const symptom of data.symptoms || []) {
|
||||
const existing = await db.symptoms.get(symptom.id)
|
||||
if (!existing || new Date(symptom.syncedAt) > new Date(existing.syncedAt)) {
|
||||
await db.symptoms.put({
|
||||
...symptom,
|
||||
recordedAt: symptom.recordedAt,
|
||||
syncedAt: symptom.syncedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync cursor
|
||||
await db.syncMeta.put({
|
||||
id: workspaceId,
|
||||
@@ -179,7 +199,7 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
|
||||
const data = await response.json()
|
||||
|
||||
// Process results and remove successful ops from outbox
|
||||
await db.transaction('rw', [db.outbox, db.appointments, db.notes], async () => {
|
||||
await db.transaction('rw', [db.outbox, db.appointments, db.notes, db.symptoms], async () => {
|
||||
for (const result of data.results) {
|
||||
if (result.success) {
|
||||
// Find the op
|
||||
@@ -198,6 +218,12 @@ export async function pushChanges(workspaceId: string): Promise<boolean> {
|
||||
await db.notes.delete(op.entityId)
|
||||
await db.notes.put({ ...local, id: result.entityId })
|
||||
}
|
||||
} else if (op.entityType === 'SYMPTOM') {
|
||||
const local = await db.symptoms.get(op.entityId)
|
||||
if (local) {
|
||||
await db.symptoms.delete(op.entityId)
|
||||
await db.symptoms.put({ ...local, id: result.entityId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +434,14 @@ export async function logDose(
|
||||
}
|
||||
|
||||
await db.doseLogs.add(doseLog)
|
||||
|
||||
// Decrement pill count if tracking is enabled
|
||||
const localMed = await db.medications.get(medicationId)
|
||||
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
|
||||
const newCount = Math.max(0, localMed.pillCount - (localMed.pillsPerDose || 1))
|
||||
await db.medications.update(medicationId, { pillCount: newCount })
|
||||
}
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId,
|
||||
type: 'TAKE_DOSE',
|
||||
@@ -424,6 +458,13 @@ export async function undoDose(doseLog: LocalDoseLog): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.doseLogs.update(doseLog.id, { undoneAt: now })
|
||||
|
||||
// Restore pill count if tracking is enabled
|
||||
const localMed = await db.medications.get(doseLog.medicationId)
|
||||
if (localMed && localMed.pillCount !== null && localMed.pillsPerDose !== null) {
|
||||
const newCount = localMed.pillCount + (localMed.pillsPerDose || 1)
|
||||
await db.medications.update(doseLog.medicationId, { pillCount: newCount })
|
||||
}
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: doseLog.workspaceId,
|
||||
type: 'UNDO_DOSE',
|
||||
@@ -445,3 +486,75 @@ export async function markQuestionAsked(note: LocalNote): Promise<void> {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unmarkQuestionAsked(note: LocalNote): Promise<void> {
|
||||
await db.notes.update(note.id, { askedAt: null })
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: note.workspaceId,
|
||||
type: 'UNMARK_ASKED',
|
||||
entityType: 'NOTE',
|
||||
entityId: note.id,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function logSymptom(
|
||||
workspaceId: string,
|
||||
data: {
|
||||
type: LocalSymptom['type']
|
||||
customName?: string
|
||||
severity: number
|
||||
notes?: string
|
||||
}
|
||||
): Promise<LocalSymptom> {
|
||||
const id = generateTempId()
|
||||
const now = new Date().toISOString()
|
||||
const symptom: LocalSymptom = {
|
||||
id,
|
||||
workspaceId,
|
||||
type: data.type,
|
||||
customName: data.customName || null,
|
||||
severity: data.severity,
|
||||
notes: data.notes || null,
|
||||
recordedAt: now,
|
||||
deletedAt: null,
|
||||
version: 1,
|
||||
syncedAt: now,
|
||||
}
|
||||
|
||||
await db.symptoms.add(symptom)
|
||||
await addToOutbox({
|
||||
workspaceId,
|
||||
type: 'LOG_SYMPTOM',
|
||||
entityType: 'SYMPTOM',
|
||||
entityId: id,
|
||||
data: {
|
||||
type: data.type,
|
||||
customName: data.customName,
|
||||
severity: data.severity,
|
||||
notes: data.notes,
|
||||
recordedAt: now,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return symptom
|
||||
}
|
||||
|
||||
export async function deleteSymptom(symptom: LocalSymptom): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.symptoms.update(symptom.id, {
|
||||
deletedAt: now,
|
||||
version: symptom.version + 1,
|
||||
syncedAt: now,
|
||||
})
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: symptom.workspaceId,
|
||||
type: 'DELETE_SYMPTOM',
|
||||
entityType: 'SYMPTOM',
|
||||
entityId: symptom.id,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,26 @@ export const updateWorkspaceSchema = z.object({
|
||||
quietHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
|
||||
quietHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).nullable().optional(),
|
||||
largeTextMode: z.boolean().optional(),
|
||||
// Emergency info fields
|
||||
patientName: z.string().max(100).nullable().optional(),
|
||||
patientDOB: z.string().datetime().nullable().optional(),
|
||||
bloodType: z.string().max(10).nullable().optional(),
|
||||
allergies: z.string().max(1000).nullable().optional(),
|
||||
medicalConditions: z.string().max(2000).nullable().optional(),
|
||||
primaryPhysician: z.string().max(100).nullable().optional(),
|
||||
physicianPhone: z.string().max(50).nullable().optional(),
|
||||
})
|
||||
|
||||
export const emergencyInfoSchema = z.object({
|
||||
patientName: z.string().max(100).nullable().optional(),
|
||||
patientDOB: z.string().datetime().nullable().optional(),
|
||||
bloodType: z.string().max(10).nullable().optional(),
|
||||
allergies: z.string().max(1000).nullable().optional(),
|
||||
medicalConditions: z.string().max(2000).nullable().optional(),
|
||||
primaryPhysician: z.string().max(100).nullable().optional(),
|
||||
physicianPhone: z.string().max(50).nullable().optional(),
|
||||
clinicPhone: z.string().max(50).nullable().optional(),
|
||||
emergencyPhone: z.string().max(50).nullable().optional(),
|
||||
})
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
@@ -108,8 +128,8 @@ export const syncQuerySchema = z.object({
|
||||
|
||||
export const syncOpSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED']),
|
||||
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG']),
|
||||
type: z.enum(['CREATE', 'UPDATE', 'DELETE', 'TAKE_DOSE', 'UNDO_DOSE', 'MARK_ASKED', 'UNMARK_ASKED', 'REFILL', 'LOG_SYMPTOM', 'DELETE_SYMPTOM']),
|
||||
entityType: z.enum(['APPOINTMENT', 'MEDICATION', 'NOTE', 'DOSE_LOG', 'SYMPTOM']),
|
||||
entityId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
timestamp: z.number(),
|
||||
@@ -120,15 +140,45 @@ export const syncOpsSchema = z.object({
|
||||
ops: z.array(syncOpSchema),
|
||||
})
|
||||
|
||||
// Symptom schemas
|
||||
export const symptomTypeEnum = z.enum(['FATIGUE', 'NAUSEA', 'PAIN', 'APPETITE', 'SLEEP', 'MOOD', 'CUSTOM'])
|
||||
|
||||
export const symptomSchema = z.object({
|
||||
type: symptomTypeEnum,
|
||||
customName: z.string().max(100).nullable().optional(),
|
||||
severity: z.number().min(1).max(5),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
recordedAt: z.string().datetime().optional(),
|
||||
})
|
||||
|
||||
// Medication refill schema
|
||||
export const refillSchema = z.object({
|
||||
pillCount: z.number().min(0).optional(),
|
||||
pillsPerDose: z.number().min(1).default(1).optional(),
|
||||
refillThreshold: z.number().min(0).default(7).optional(),
|
||||
lastRefillDate: z.string().datetime().nullable().optional(),
|
||||
})
|
||||
|
||||
export const medicationWithRefillSchema = medicationSchema.extend({
|
||||
pillCount: z.number().min(0).nullable().optional(),
|
||||
pillsPerDose: z.number().min(1).nullable().optional(),
|
||||
refillThreshold: z.number().min(0).nullable().optional(),
|
||||
lastRefillDate: z.string().datetime().nullable().optional(),
|
||||
})
|
||||
|
||||
// Type exports
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type RegisterInput = z.infer<typeof registerSchema>
|
||||
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceSchema>
|
||||
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceSchema>
|
||||
export type EmergencyInfoInput = z.infer<typeof emergencyInfoSchema>
|
||||
export type InviteInput = z.infer<typeof inviteSchema>
|
||||
export type AppointmentInput = z.infer<typeof appointmentSchema>
|
||||
export type MedicationInput = z.infer<typeof medicationSchema>
|
||||
export type MedicationWithRefillInput = z.infer<typeof medicationWithRefillSchema>
|
||||
export type ScheduleDataInput = z.infer<typeof scheduleDataSchema>
|
||||
export type DoseLogInput = z.infer<typeof doseLogSchema>
|
||||
export type NoteInput = z.infer<typeof noteSchema>
|
||||
export type SymptomInput = z.infer<typeof symptomSchema>
|
||||
export type SymptomType = z.infer<typeof symptomTypeEnum>
|
||||
export type SyncOp = z.infer<typeof syncOpSchema>
|
||||
|
||||
215
src/styles/print.css
Normal file
215
src/styles/print.css
Normal file
@@ -0,0 +1,215 @@
|
||||
/* Print-specific styles */
|
||||
|
||||
@media print {
|
||||
/* Hide navigation and non-essential elements */
|
||||
nav,
|
||||
.no-print,
|
||||
button:not(.print-button),
|
||||
.bottom-nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reset background colors for printing */
|
||||
body,
|
||||
.bg-surface,
|
||||
.bg-background {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Ensure text is readable */
|
||||
* {
|
||||
color-adjust: exact !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Page settings */
|
||||
@page {
|
||||
margin: 0.75in;
|
||||
size: letter;
|
||||
}
|
||||
|
||||
/* Prevent page breaks inside elements */
|
||||
.print-no-break {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Force page breaks */
|
||||
.print-page-break {
|
||||
page-break-before: always;
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
/* Print-specific sizes */
|
||||
.print-title {
|
||||
font-size: 24pt !important;
|
||||
font-weight: bold !important;
|
||||
margin-bottom: 12pt !important;
|
||||
}
|
||||
|
||||
.print-subtitle {
|
||||
font-size: 14pt !important;
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 8pt !important;
|
||||
}
|
||||
|
||||
.print-text {
|
||||
font-size: 12pt !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.print-text-large {
|
||||
font-size: 14pt !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
/* Large checkboxes for daily meds */
|
||||
.print-checkbox {
|
||||
width: 24pt !important;
|
||||
height: 24pt !important;
|
||||
border: 2pt solid black !important;
|
||||
display: inline-block !important;
|
||||
margin-right: 8pt !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.print-table {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
margin-bottom: 12pt !important;
|
||||
}
|
||||
|
||||
.print-table th,
|
||||
.print-table td {
|
||||
border: 1pt solid #333 !important;
|
||||
padding: 6pt 8pt !important;
|
||||
text-align: left !important;
|
||||
font-size: 11pt !important;
|
||||
}
|
||||
|
||||
.print-table th {
|
||||
background-color: #f0f0f0 !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
/* Section spacing */
|
||||
.print-section {
|
||||
margin-bottom: 24pt !important;
|
||||
}
|
||||
|
||||
/* Date header */
|
||||
.print-date {
|
||||
font-size: 12pt !important;
|
||||
color: #666 !important;
|
||||
margin-bottom: 16pt !important;
|
||||
}
|
||||
|
||||
/* Emergency info box */
|
||||
.print-emergency-box {
|
||||
border: 2pt solid #dc2626 !important;
|
||||
padding: 12pt !important;
|
||||
margin-bottom: 16pt !important;
|
||||
}
|
||||
|
||||
/* Medication list item */
|
||||
.print-med-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 8pt 0 !important;
|
||||
border-bottom: 1pt solid #ccc !important;
|
||||
}
|
||||
|
||||
.print-med-name {
|
||||
font-weight: bold !important;
|
||||
font-size: 14pt !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.print-med-time {
|
||||
font-size: 12pt !important;
|
||||
min-width: 80pt !important;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
/* Notes section */
|
||||
.print-notes {
|
||||
border: 1pt solid #ccc !important;
|
||||
padding: 8pt !important;
|
||||
min-height: 48pt !important;
|
||||
background: #fafafa !important;
|
||||
}
|
||||
|
||||
/* Question list */
|
||||
.print-question {
|
||||
padding: 8pt 0 !important;
|
||||
border-bottom: 1pt solid #eee !important;
|
||||
}
|
||||
|
||||
.print-question-checkbox {
|
||||
width: 16pt !important;
|
||||
height: 16pt !important;
|
||||
border: 1.5pt solid black !important;
|
||||
display: inline-block !important;
|
||||
margin-right: 8pt !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.print-footer {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
text-align: center !important;
|
||||
font-size: 9pt !important;
|
||||
color: #666 !important;
|
||||
padding: 8pt !important;
|
||||
}
|
||||
|
||||
/* Hide interactive elements */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1pt solid #ccc !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Link styling */
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
a[href]::after {
|
||||
content: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen styles for print preview */
|
||||
@media screen {
|
||||
.print-preview {
|
||||
max-width: 8.5in;
|
||||
margin: 0 auto;
|
||||
padding: 0.75in;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
min-height: 11in;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.screen-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user