mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 13:21:39 +08:00
Initial commit: Next Step health management app
A calm, reliable app to help manage appointments, medications, and notes for chemo patients and their families. Features: - Today dashboard with next appointment and medications due - Medication tracking with multiple schedule types (fixed times, interval, weekdays, PRN) - One-tap dose logging with 5-minute undo window - Questions for doctor tracking - Family sharing with workspace model and invite links - Offline-first with IndexedDB and sync - Docker Compose deployment with Tailscale Funnel support Tech stack: - Next.js 14 (App Router) + TypeScript + Tailwind CSS - PostgreSQL + Prisma - Argon2 password hashing + session cookies - Dexie.js for IndexedDB Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
.dockerignore
Normal file
52
.dockerignore
Normal file
@@ -0,0 +1,52 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# Production
|
||||
build
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
README.md
|
||||
*.md
|
||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://nextstep:nextstep@localhost:5432/nextstep?schema=public"
|
||||
|
||||
# App Configuration
|
||||
NEXTAUTH_SECRET="generate-a-random-secret-here-at-least-32-chars"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# Timezone (important for medication scheduling)
|
||||
TZ="Australia/Perth"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
|
||||
# Login Security
|
||||
LOGIN_MAX_ATTEMPTS=5
|
||||
LOGIN_LOCKOUT_MINUTES=15
|
||||
|
||||
# Session
|
||||
SESSION_MAX_AGE_DAYS=30
|
||||
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TZ=Australia/Perth
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Install tzdata for timezone support
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Run database migrations before starting
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
|
||||
318
README.md
Normal file
318
README.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# Next Step
|
||||
|
||||
A calm, reliable health management app for families supporting a loved one through treatment. Built to be "mum-proof" — simple, clear, and accessible.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Today Dashboard
|
||||
- **Next appointment** with location and map link
|
||||
- **Medications due** with one-tap "Taken" button
|
||||
- **Quick note** for jotting down thoughts
|
||||
- **Call clinic** button for easy access
|
||||
|
||||
### Appointments
|
||||
- Simple timeline view with date groupings
|
||||
- Add title, date/time, location, map link, and notes
|
||||
- Soft delete with recovery
|
||||
|
||||
### Medications
|
||||
- **Multiple schedule types:**
|
||||
- Fixed times daily (e.g., 8am, 8pm)
|
||||
- Every X hours (e.g., every 8 hours)
|
||||
- Specific weekdays (e.g., Mon/Wed/Fri at 9am)
|
||||
- PRN/As needed with cooldown period
|
||||
- One-tap dose logging with 5-minute undo window
|
||||
- "What did I take?" history view (last 7 days)
|
||||
- Overdue indicators with grace period
|
||||
|
||||
### Notes
|
||||
- **Questions for doctor** — track what to ask, mark as asked
|
||||
- **General notes** — timestamped thoughts
|
||||
- Copy questions for appointments
|
||||
|
||||
### Family Sharing
|
||||
- Workspace model (e.g., "Grace's Plan")
|
||||
- Invite family via link
|
||||
- Roles: Owner, Editor, Viewer
|
||||
- Audit log of all changes
|
||||
|
||||
### Offline-First
|
||||
- Works without internet connection
|
||||
- IndexedDB local cache
|
||||
- Automatic sync when online
|
||||
- Conflict detection with "updated on another device" banner
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS
|
||||
- **Database:** PostgreSQL with Prisma ORM
|
||||
- **Auth:** Session cookies with argon2 password hashing
|
||||
- **Offline:** IndexedDB via Dexie.js
|
||||
- **Deployment:** Docker Compose
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Tailscale (for external access)
|
||||
|
||||
### 1. Clone and Configure
|
||||
|
||||
```bash
|
||||
cd /path/to/nextstep
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and set:
|
||||
# - NEXTAUTH_SECRET (generate with: openssl rand -base64 32)
|
||||
# - DB_PASSWORD (choose a secure password)
|
||||
# - NEXT_PUBLIC_APP_URL (your Tailscale Funnel URL)
|
||||
```
|
||||
|
||||
### 2. Start the Application
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will:
|
||||
1. Build the Next.js application
|
||||
2. Start PostgreSQL
|
||||
3. Run database migrations
|
||||
4. Start the app on `127.0.0.1:3000`
|
||||
|
||||
### 3. Set Up Tailscale Funnel
|
||||
|
||||
Tailscale Funnel exposes your local app to the internet with automatic HTTPS.
|
||||
|
||||
```bash
|
||||
# Enable Funnel (one-time setup)
|
||||
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
|
||||
|
||||
# Check status
|
||||
tailscale funnel status
|
||||
|
||||
# Your app is now accessible at:
|
||||
# https://[your-machine-name].[your-tailnet].ts.net
|
||||
```
|
||||
|
||||
### 4. Update Your Environment
|
||||
|
||||
Edit `.env` and set `NEXT_PUBLIC_APP_URL` to your Funnel URL:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_APP_URL=https://your-machine.your-tailnet.ts.net
|
||||
```
|
||||
|
||||
Then restart the app:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 5. Create Your Account
|
||||
|
||||
1. Open your Funnel URL in a browser
|
||||
2. Click "Create Account"
|
||||
3. Accept the disclaimer
|
||||
4. Create your workspace (e.g., "Grace's Plan")
|
||||
5. Add your clinic phone number
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Set up local PostgreSQL (or use Docker)
|
||||
docker run -d \
|
||||
--name nextstep-postgres \
|
||||
-e POSTGRES_USER=nextstep \
|
||||
-e POSTGRES_PASSWORD=nextstep \
|
||||
-e POSTGRES_DB=nextstep \
|
||||
-p 5432:5432 \
|
||||
postgres:16-alpine
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with DATABASE_URL=postgresql://nextstep:nextstep@localhost:5432/nextstep
|
||||
|
||||
# Generate Prisma client
|
||||
npm run db:generate
|
||||
|
||||
# Run migrations
|
||||
npm run db:migrate
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
- Medication scheduling logic
|
||||
- PRN cooldown calculations
|
||||
- Fixed times, interval, and weekday schedules
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Yes |
|
||||
| `NEXTAUTH_SECRET` | Session encryption secret (min 32 chars) | Yes |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public URL of the app | Yes |
|
||||
| `DB_PASSWORD` | PostgreSQL password (for Docker) | Yes |
|
||||
| `TZ` | Timezone (default: Australia/Perth) | No |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per minute (default: 100) | No |
|
||||
| `LOGIN_MAX_ATTEMPTS` | Failed logins before lockout (default: 5) | No |
|
||||
| `LOGIN_LOCKOUT_MINUTES` | Lockout duration (default: 15) | No |
|
||||
| `SESSION_MAX_AGE_DAYS` | Session lifetime (default: 30) | No |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/register` — Create account
|
||||
- `POST /api/auth/login` — Sign in
|
||||
- `POST /api/auth/logout` — Sign out
|
||||
- `GET /api/auth/me` — Get current user
|
||||
|
||||
### Workspaces
|
||||
- `GET /api/workspaces` — List user's workspaces
|
||||
- `POST /api/workspaces` — Create workspace
|
||||
- `GET /api/workspaces/[id]` — Get workspace details
|
||||
- `PATCH /api/workspaces/[id]` — Update workspace settings
|
||||
- `POST /api/workspaces/[id]/invite` — Create invite link
|
||||
- `GET /api/invite/[token]` — Get invite details
|
||||
- `POST /api/invite/[token]` — Accept invite
|
||||
|
||||
### Sync
|
||||
- `GET /api/sync?workspaceId=...&since=...` — Pull changes
|
||||
- `POST /api/sync` — Push offline operations
|
||||
|
||||
### Health
|
||||
- `GET /api/health` — Health check
|
||||
|
||||
## Security
|
||||
|
||||
- **Password hashing:** Argon2id with secure parameters
|
||||
- **Session cookies:** HTTPOnly, Secure, SameSite=Lax
|
||||
- **Rate limiting:** Per-IP request limits
|
||||
- **Login protection:** Lockout after failed attempts
|
||||
- **Input validation:** Zod schemas on all endpoints
|
||||
- **HTTPS:** Enforced via Tailscale Funnel
|
||||
|
||||
## Tailscale Funnel Commands
|
||||
|
||||
```bash
|
||||
# Start Funnel (background mode)
|
||||
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
|
||||
|
||||
# Check Funnel status
|
||||
tailscale funnel status
|
||||
|
||||
# Stop Funnel
|
||||
tailscale funnel off
|
||||
|
||||
# View Funnel logs
|
||||
tailscale funnel status --json
|
||||
```
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
docker exec nextstep-db pg_dump -U nextstep nextstep > backup.sql
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
cat backup.sql | docker exec -i nextstep-db psql -U nextstep nextstep
|
||||
```
|
||||
|
||||
### Export User Data
|
||||
|
||||
Users can export their data as JSON from Settings > Export Data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App won't start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs app
|
||||
|
||||
# Common issues:
|
||||
# - Database not ready: wait a few seconds, it will retry
|
||||
# - Missing env vars: check .env file
|
||||
```
|
||||
|
||||
### Database connection failed
|
||||
|
||||
```bash
|
||||
# Check database is running
|
||||
docker compose ps
|
||||
|
||||
# Check database logs
|
||||
docker compose logs db
|
||||
|
||||
# Verify connection string in .env
|
||||
```
|
||||
|
||||
### Tailscale Funnel not working
|
||||
|
||||
```bash
|
||||
# Ensure Funnel is enabled for your tailnet
|
||||
# (requires admin access to Tailscale admin console)
|
||||
|
||||
# Check if Funnel is running
|
||||
tailscale funnel status
|
||||
|
||||
# Restart Funnel
|
||||
tailscale funnel off
|
||||
tailscale funnel --https=443 http://127.0.0.1:3000 --bg
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Tailscale Funnel │
|
||||
│ (HTTPS termination) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Next.js App │
|
||||
│ (127.0.0.1:3000) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ App │ │ API │ │ Auth │ │
|
||||
│ │ Router │ │ Routes │ │ (Sessions) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Internal only) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See LICENSE file for details.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Next Step is a tracking tool only.** It does not provide medical advice. Always consult your healthcare team for medical decisions. For emergencies, call 000 (Australia) or your local emergency services.
|
||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nextstep-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Bind to localhost only for Tailscale Funnel
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://nextstep:${DB_PASSWORD:-nextstep}@db:5432/nextstep?schema=public
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
- TZ=Australia/Perth
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- nextstep-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: nextstep-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=nextstep
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-nextstep}
|
||||
- POSTGRES_DB=nextstep
|
||||
- TZ=Australia/Perth
|
||||
- PGTZ=Australia/Perth
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- nextstep-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U nextstep -d nextstep"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
# Do not expose PostgreSQL to the host - only accessible within the network
|
||||
# If you need direct access, uncomment below:
|
||||
# ports:
|
||||
# - "127.0.0.1:5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: nextstep-postgres-data
|
||||
|
||||
networks:
|
||||
nextstep-network:
|
||||
name: nextstep-network
|
||||
driver: bridge
|
||||
12
next.config.js
Normal file
12
next.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
// Output standalone for Docker
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
54
package.json
Normal file
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "next-step",
|
||||
"version": "1.0.0",
|
||||
"description": "Family health management app for chemo patients",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"argon2": "^0.41.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dexie": "^4.0.10",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"lucide-react": "^0.468.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "^14.2.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.21",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
307
prisma/schema.prisma
Normal file
307
prisma/schema.prisma
Normal file
@@ -0,0 +1,307 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USER & AUTHENTICATION
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
sessions Session[]
|
||||
workspaceMembers WorkspaceMember[]
|
||||
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
|
||||
updatedAppointments Appointment[] @relation("AppointmentUpdatedBy")
|
||||
createdMedications Medication[] @relation("MedicationCreatedBy")
|
||||
updatedMedications Medication[] @relation("MedicationUpdatedBy")
|
||||
createdNotes Note[] @relation("NoteCreatedBy")
|
||||
updatedNotes Note[] @relation("NoteUpdatedBy")
|
||||
loggedDoses DoseLog[] @relation("DoseLoggedBy")
|
||||
undoneDoses DoseLog[] @relation("DoseUndoneBy")
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
userAgent String?
|
||||
ipAddress String?
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model LoginAttempt {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
ipAddress String?
|
||||
success Boolean
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([email, createdAt])
|
||||
@@index([ipAddress, createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WORKSPACE & SHARING
|
||||
// ============================================
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(cuid())
|
||||
name String // e.g., "Grace's Plan"
|
||||
clinicPhone String?
|
||||
emergencyPhone String?
|
||||
quietHoursStart String? // HH:mm format
|
||||
quietHoursEnd String? // HH:mm format
|
||||
largeTextMode Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
members WorkspaceMember[]
|
||||
inviteTokens InviteToken[]
|
||||
appointments Appointment[]
|
||||
medications Medication[]
|
||||
notes Note[]
|
||||
doseLogs DoseLog[]
|
||||
auditLogs AuditLog[]
|
||||
syncCursors SyncCursor[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
enum WorkspaceRole {
|
||||
OWNER
|
||||
EDITOR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model WorkspaceMember {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
userId String
|
||||
role WorkspaceRole @default(VIEWER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model InviteToken {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
token String @unique
|
||||
role WorkspaceRole @default(VIEWER)
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
usedById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPOINTMENTS
|
||||
// ============================================
|
||||
|
||||
model Appointment {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
title String
|
||||
datetime DateTime
|
||||
location String?
|
||||
mapUrl String?
|
||||
notes String?
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String
|
||||
updatedById String
|
||||
|
||||
// Sync tracking
|
||||
version Int @default(1)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
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])
|
||||
|
||||
@@index([workspaceId, datetime])
|
||||
@@index([workspaceId, deletedAt])
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEDICATIONS
|
||||
// ============================================
|
||||
|
||||
enum ScheduleType {
|
||||
FIXED_TIMES // e.g., 08:00, 20:00 daily
|
||||
INTERVAL // every X hours
|
||||
WEEKDAYS // specific weekdays at a time
|
||||
PRN // as needed with min hours between
|
||||
}
|
||||
|
||||
model Medication {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
name String
|
||||
instructions String?
|
||||
scheduleType ScheduleType
|
||||
scheduleData Json // Flexible storage for schedule details
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
active Boolean @default(true)
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String
|
||||
updatedById String
|
||||
|
||||
// Sync tracking
|
||||
version Int @default(1)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("MedicationCreatedBy", fields: [createdById], references: [id])
|
||||
updatedBy User @relation("MedicationUpdatedBy", fields: [updatedById], references: [id])
|
||||
doseLogs DoseLog[]
|
||||
|
||||
@@index([workspaceId, active])
|
||||
@@index([workspaceId, deletedAt])
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// Schedule data shapes (stored as JSON):
|
||||
// FIXED_TIMES: { times: ["08:00", "20:00"] }
|
||||
// INTERVAL: { hours: 8, startTime: "08:00" }
|
||||
// WEEKDAYS: { days: [1, 3, 5], time: "09:00" } // Monday, Wednesday, Friday
|
||||
// PRN: { minHoursBetween: 4 }
|
||||
|
||||
model DoseLog {
|
||||
id String @id @default(cuid())
|
||||
medicationId String
|
||||
workspaceId String
|
||||
takenAt DateTime
|
||||
loggedById String
|
||||
undoneAt DateTime?
|
||||
undoneById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Sync tracking (append-only, never overwritten)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
loggedBy User @relation("DoseLoggedBy", fields: [loggedById], references: [id])
|
||||
undoneBy User? @relation("DoseUndoneBy", fields: [undoneById], references: [id])
|
||||
|
||||
@@index([medicationId, takenAt])
|
||||
@@index([workspaceId, takenAt])
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NOTES
|
||||
// ============================================
|
||||
|
||||
enum NoteType {
|
||||
QUESTION // Questions for doctor
|
||||
GENERAL // General notes
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
type NoteType
|
||||
content String
|
||||
askedAt DateTime? // When a question was asked (marked)
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String
|
||||
updatedById String
|
||||
|
||||
// Sync tracking
|
||||
version Int @default(1)
|
||||
syncedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("NoteCreatedBy", fields: [createdById], references: [id])
|
||||
updatedBy User @relation("NoteUpdatedBy", fields: [updatedById], references: [id])
|
||||
|
||||
@@index([workspaceId, type])
|
||||
@@index([workspaceId, deletedAt])
|
||||
@@index([syncedAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUDIT LOG
|
||||
// ============================================
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
userId String
|
||||
action String // CREATE, UPDATE, DELETE, TAKE_DOSE, UNDO_DOSE, etc.
|
||||
entityType String // APPOINTMENT, MEDICATION, NOTE, DOSE_LOG
|
||||
entityId String
|
||||
details Json? // Additional context
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([workspaceId, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SYNC
|
||||
// ============================================
|
||||
|
||||
model SyncCursor {
|
||||
id String @id @default(cuid())
|
||||
workspaceId String
|
||||
cursor BigInt @default(0) // Timestamp-based cursor
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId])
|
||||
}
|
||||
24
public/manifest.json
Normal file
24
public/manifest.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Next Step",
|
||||
"short_name": "Next Step",
|
||||
"description": "Health management made simple",
|
||||
"start_url": "/today",
|
||||
"display": "standalone",
|
||||
"background_color": "#fafbfc",
|
||||
"theme_color": "#3a9563",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
146
src/app/(app)/appointments/new/page.tsx
Normal file
146
src/app/(app)/appointments/new/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Button, Input, Textarea, Card, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function NewAppointmentPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'))
|
||||
const [time, setTime] = useState('09:00')
|
||||
const [location, setLocation] = useState('')
|
||||
const [mapUrl, setMapUrl] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Combine date and time
|
||||
const datetime = new Date(`${date}T${time}:00`)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/appointments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
datetime: datetime.toISOString(),
|
||||
location: location || null,
|
||||
mapUrl: mapUrl || null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to create appointment')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast('Appointment added', 'success')
|
||||
router.push('/appointments')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="New Appointment" showBack backHref="/appointments" />
|
||||
<PageContainer className="pt-4">
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Oncology Appointment"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g., Level 3, Cancer Centre"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Map Link (optional)"
|
||||
type="url"
|
||||
value={mapUrl}
|
||||
onChange={(e) => setMapUrl(e.target.value)}
|
||||
placeholder="https://maps.google.com/..."
|
||||
helperText="Paste a Google Maps or Apple Maps link"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any notes for this appointment..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Save Appointment
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
164
src/app/(app)/appointments/page.tsx
Normal file
164
src/app/(app)/appointments/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
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 { 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 { useApp } from '../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
|
||||
const appointments = useLiveQuery(
|
||||
() =>
|
||||
db.appointments
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((a) => !a.deletedAt)
|
||||
.sortBy('datetime'),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Group appointments by date
|
||||
const groupedAppointments = appointments?.reduce(
|
||||
(groups, appt) => {
|
||||
const date = toZonedTime(parseISO(appt.datetime), TIMEZONE)
|
||||
const dateKey = format(startOfDay(date), 'yyyy-MM-dd')
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = {
|
||||
date,
|
||||
appointments: [],
|
||||
}
|
||||
}
|
||||
groups[dateKey].appointments.push(appt)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, { date: Date; appointments: typeof appointments }>
|
||||
)
|
||||
|
||||
const sortedDates = Object.keys(groupedAppointments || {}).sort()
|
||||
|
||||
const formatDateHeader = (date: Date) => {
|
||||
if (isToday(date)) return 'Today'
|
||||
if (isTomorrow(date)) return 'Tomorrow'
|
||||
return format(date, 'EEEE, MMMM d')
|
||||
}
|
||||
|
||||
if (!appointments) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Appointments"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add appointment',
|
||||
onClick: () => router.push('/appointments/new'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading appointments..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Appointments"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add appointment',
|
||||
onClick: () => router.push('/appointments/new'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
{appointments.length === 0 ? (
|
||||
<EmptyState
|
||||
type="appointments"
|
||||
title="No appointments"
|
||||
description="Add your upcoming appointments to keep track of them."
|
||||
action={{
|
||||
label: 'Add Appointment',
|
||||
onClick: () => router.push('/appointments/new'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const group = groupedAppointments![dateKey]
|
||||
const isPast = group.date < startOfDay(new Date())
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
<h2
|
||||
className={`text-sm font-semibold mb-3 ${
|
||||
isPast ? 'text-secondary-400' : 'text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{formatDateHeader(group.date)}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{group.appointments.map((appt) => (
|
||||
<Card
|
||||
key={appt.id}
|
||||
onClick={() => router.push(`/appointments/${appt.id}`)}
|
||||
className={`${isPast ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isPast
|
||||
? 'bg-secondary-100'
|
||||
: 'bg-primary-100'
|
||||
}`}
|
||||
>
|
||||
<Calendar
|
||||
className={`w-5 h-5 ${
|
||||
isPast
|
||||
? 'text-secondary-400'
|
||||
: 'text-primary-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900 truncate">
|
||||
{appt.title}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{format(
|
||||
toZonedTime(parseISO(appt.datetime), TIMEZONE),
|
||||
'h:mm a'
|
||||
)}
|
||||
</p>
|
||||
{appt.location && (
|
||||
<p className="text-sm text-secondary-400 flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="truncate">{appt.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
53
src/app/(app)/layout.tsx
Normal file
53
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||
import { AppProvider } from './provider'
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Get user's workspaces
|
||||
const memberships = await prisma.workspaceMember.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// If no workspaces, user needs to create one or accept invite
|
||||
if (memberships.length === 0) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
const workspaces = memberships.map((m) => ({
|
||||
id: m.workspace.id,
|
||||
name: m.workspace.name,
|
||||
role: m.role,
|
||||
clinicPhone: m.workspace.clinicPhone,
|
||||
emergencyPhone: m.workspace.emergencyPhone,
|
||||
largeTextMode: m.workspace.largeTextMode,
|
||||
}))
|
||||
|
||||
return (
|
||||
<AppProvider
|
||||
user={session.user}
|
||||
workspaces={workspaces}
|
||||
initialWorkspaceId={workspaces[0].id}
|
||||
>
|
||||
<div className={workspaces[0].largeTextMode ? 'large-text' : ''}>
|
||||
{children}
|
||||
<BottomNav />
|
||||
</div>
|
||||
</AppProvider>
|
||||
)
|
||||
}
|
||||
148
src/app/(app)/meds/history/page.tsx
Normal file
148
src/app/(app)/meds/history/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { format, isToday, isYesterday, parseISO, startOfDay, subDays } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import { Pill, Clock, X } 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 { useApp } from '../../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function MedicationHistoryPage() {
|
||||
const { currentWorkspace } = useApp()
|
||||
|
||||
// Get last 7 days of doses
|
||||
const sevenDaysAgo = subDays(new Date(), 7)
|
||||
|
||||
const doseLogs = useLiveQuery(
|
||||
() =>
|
||||
db.doseLogs
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((d) => new Date(d.takenAt) >= sevenDaysAgo)
|
||||
.reverse()
|
||||
.sortBy('takenAt'),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Group by date
|
||||
const groupedDoses = doseLogs?.reduce(
|
||||
(groups, dose) => {
|
||||
const date = toZonedTime(parseISO(dose.takenAt), TIMEZONE)
|
||||
const dateKey = format(startOfDay(date), 'yyyy-MM-dd')
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = {
|
||||
date,
|
||||
doses: [],
|
||||
}
|
||||
}
|
||||
groups[dateKey].doses.push(dose)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, { date: Date; doses: typeof doseLogs }>
|
||||
)
|
||||
|
||||
const sortedDates = Object.keys(groupedDoses || {}).sort().reverse()
|
||||
|
||||
const formatDateHeader = (date: Date) => {
|
||||
if (isToday(date)) return 'Today'
|
||||
if (isYesterday(date)) return 'Yesterday'
|
||||
return format(date, 'EEEE, MMMM d')
|
||||
}
|
||||
|
||||
if (!doseLogs) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Dose History" showBack backHref="/meds" />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading history..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Dose History" showBack backHref="/meds" />
|
||||
<PageContainer className="pt-4">
|
||||
<p className="text-sm text-secondary-500 mb-4">Last 7 days</p>
|
||||
|
||||
{doseLogs.length === 0 ? (
|
||||
<EmptyState
|
||||
type="medications"
|
||||
title="No doses recorded"
|
||||
description="Doses you take will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const group = groupedDoses![dateKey]
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
{formatDateHeader(group.date)}
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<div className="divide-y divide-border">
|
||||
{group.doses.map((dose) => (
|
||||
<div
|
||||
key={dose.id}
|
||||
className={`flex items-center gap-3 p-4 ${
|
||||
dose.undoneAt ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
dose.undoneAt ? 'bg-secondary-100' : 'bg-primary-100'
|
||||
}`}
|
||||
>
|
||||
<Pill
|
||||
className={`w-4 h-4 ${
|
||||
dose.undoneAt ? 'text-secondary-400' : 'text-primary-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
dose.undoneAt
|
||||
? 'text-secondary-400 line-through'
|
||||
: 'text-secondary-900'
|
||||
}`}
|
||||
>
|
||||
{dose.medication?.name || 'Unknown medication'}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500 flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{format(
|
||||
toZonedTime(parseISO(dose.takenAt), TIMEZONE),
|
||||
'h:mm a'
|
||||
)}
|
||||
{dose.loggedBy && ` • ${dose.loggedBy.name}`}
|
||||
</p>
|
||||
</div>
|
||||
{dose.undoneAt && (
|
||||
<div className="flex items-center gap-1 text-secondary-400">
|
||||
<X className="w-4 h-4" />
|
||||
<span className="text-xs">Undone</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
275
src/app/(app)/meds/new/page.tsx
Normal file
275
src/app/(app)/meds/new/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Input, Textarea, Select, Card, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||
|
||||
const scheduleTypeOptions = [
|
||||
{ value: 'FIXED_TIMES', label: 'Fixed times daily' },
|
||||
{ value: 'INTERVAL', label: 'Every X hours' },
|
||||
{ value: 'WEEKDAYS', label: 'Specific days of week' },
|
||||
{ value: 'PRN', label: 'As needed (PRN)' },
|
||||
]
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
]
|
||||
|
||||
export default function NewMedicationPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('FIXED_TIMES')
|
||||
|
||||
// Fixed times
|
||||
const [times, setTimes] = useState(['08:00'])
|
||||
|
||||
// Interval
|
||||
const [intervalHours, setIntervalHours] = useState(8)
|
||||
const [startTime, setStartTime] = useState('08:00')
|
||||
|
||||
// Weekdays
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([1, 3, 5])
|
||||
const [weekdayTime, setWeekdayTime] = useState('09:00')
|
||||
|
||||
// PRN
|
||||
const [minHoursBetween, setMinHoursBetween] = useState(4)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const addTime = () => {
|
||||
setTimes([...times, '12:00'])
|
||||
}
|
||||
|
||||
const removeTime = (index: number) => {
|
||||
setTimes(times.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateTime = (index: number, value: string) => {
|
||||
const newTimes = [...times]
|
||||
newTimes[index] = value
|
||||
setTimes(newTimes)
|
||||
}
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
if (selectedDays.includes(day)) {
|
||||
setSelectedDays(selectedDays.filter((d) => d !== day))
|
||||
} else {
|
||||
setSelectedDays([...selectedDays, day].sort())
|
||||
}
|
||||
}
|
||||
|
||||
const buildScheduleData = () => {
|
||||
switch (scheduleType) {
|
||||
case 'FIXED_TIMES':
|
||||
return { type: 'FIXED_TIMES', times }
|
||||
case 'INTERVAL':
|
||||
return { type: 'INTERVAL', hours: intervalHours, startTime }
|
||||
case 'WEEKDAYS':
|
||||
return { type: 'WEEKDAYS', days: selectedDays, time: weekdayTime }
|
||||
case 'PRN':
|
||||
return { type: 'PRN', minHoursBetween }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/${currentWorkspace.id}/medications`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
instructions: instructions || null,
|
||||
scheduleType,
|
||||
scheduleData: buildScheduleData(),
|
||||
active: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to add medication')
|
||||
}
|
||||
|
||||
await refreshData()
|
||||
showToast('Medication added', 'success')
|
||||
router.push('/meds')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="New Medication" showBack backHref="/meds" />
|
||||
<PageContainer className="pt-4">
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Input
|
||||
label="Medication Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Paracetamol 500mg"
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Instructions (optional)"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="e.g., Take with food"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Schedule Type"
|
||||
value={scheduleType}
|
||||
onChange={(e) => setScheduleType(e.target.value as ScheduleType)}
|
||||
options={scheduleTypeOptions}
|
||||
/>
|
||||
|
||||
{/* Schedule-specific options */}
|
||||
{scheduleType === 'FIXED_TIMES' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-secondary-700">
|
||||
Times to take
|
||||
</label>
|
||||
{times.map((time, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => updateTime(index, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{times.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeTime(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="secondary" onClick={addTime} size="sm">
|
||||
+ Add time
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'INTERVAL' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Every (hours)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={72}
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<Input
|
||||
label="Starting at"
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'WEEKDAYS' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Days
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{weekdays.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={`px-3 py-2 rounded-button text-sm font-medium transition-colors ${
|
||||
selectedDays.includes(day.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Time"
|
||||
type="time"
|
||||
value={weekdayTime}
|
||||
onChange={(e) => setWeekdayTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scheduleType === 'PRN' && (
|
||||
<Input
|
||||
label="Minimum hours between doses"
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={72}
|
||||
step={0.5}
|
||||
value={minHoursBetween}
|
||||
onChange={(e) => setMinHoursBetween(parseFloat(e.target.value) || 4)}
|
||||
helperText="Shows 'Available' when enough time has passed since last dose"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Save Medication
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
258
src/app/(app)/meds/page.tsx
Normal file
258
src/app/(app)/meds/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plus, Pill, Clock, ChevronRight, History } from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
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 { useApp } from '../provider'
|
||||
|
||||
export default function MedsPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace } = useApp()
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
// Update time every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Fetch data from IndexedDB
|
||||
const medications = useLiveQuery(
|
||||
() =>
|
||||
db.medications
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((m) => !m.deletedAt)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const doseLogs = useLiveQuery(
|
||||
() =>
|
||||
db.doseLogs
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Calculate medication due statuses
|
||||
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (medications && doseLogs) {
|
||||
const meds = medications
|
||||
.filter((m) => m.active)
|
||||
.map((m) => ({
|
||||
...m,
|
||||
scheduleData: m.scheduleData as Medication['scheduleData'],
|
||||
startDate: m.startDate ? new Date(m.startDate) : null,
|
||||
endDate: m.endDate ? new Date(m.endDate) : null,
|
||||
})) as Medication[]
|
||||
|
||||
const logs = doseLogs.map((d) => ({
|
||||
...d,
|
||||
takenAt: new Date(d.takenAt),
|
||||
undoneAt: d.undoneAt ? new Date(d.undoneAt) : null,
|
||||
})) as DoseLog[]
|
||||
|
||||
const statuses = calculateAllMedicationsDue(meds, now, logs)
|
||||
setMedStatuses(statuses)
|
||||
}
|
||||
}, [medications, doseLogs, now])
|
||||
|
||||
// Inactive medications
|
||||
const inactiveMeds = medications?.filter((m) => !m.active) || []
|
||||
|
||||
const handleTakeMed = useCallback(
|
||||
async (status: MedicationDueStatus) => {
|
||||
try {
|
||||
const doseLog = await logDose(
|
||||
currentWorkspace.id,
|
||||
status.medication.id,
|
||||
{ id: status.medication.id, name: status.medication.name }
|
||||
)
|
||||
|
||||
showUndoToast(`Took ${status.medication.name}`, async () => {
|
||||
await undoDose(doseLog)
|
||||
showToast('Dose undone', 'info')
|
||||
})
|
||||
} catch {
|
||||
showToast('Failed to log dose', 'error')
|
||||
}
|
||||
},
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
if (!medications) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Medications"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add medication',
|
||||
onClick: () => router.push('/meds/new'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading medications..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Medications"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add medication',
|
||||
onClick: () => router.push('/meds/new'),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
{/* History link */}
|
||||
<button
|
||||
onClick={() => router.push('/meds/history')}
|
||||
className="flex items-center gap-2 text-primary-600 font-medium mb-4"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
View dose history
|
||||
</button>
|
||||
|
||||
{medications.length === 0 ? (
|
||||
<EmptyState
|
||||
type="medications"
|
||||
title="No medications"
|
||||
description="Add medications you need to track."
|
||||
action={{
|
||||
label: 'Add Medication',
|
||||
onClick: () => router.push('/meds/new'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active medications */}
|
||||
{medStatuses.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Active Medications
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{medStatuses.map((status) => (
|
||||
<MedicationCard
|
||||
key={status.medication.id}
|
||||
status={status}
|
||||
now={now}
|
||||
onTake={() => handleTakeMed(status)}
|
||||
onClick={() => router.push(`/meds/${status.medication.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Inactive medications */}
|
||||
{inactiveMeds.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-400 mb-3">
|
||||
Inactive Medications
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{inactiveMeds.map((med) => (
|
||||
<Card
|
||||
key={med.id}
|
||||
onClick={() => router.push(`/meds/${med.id}`)}
|
||||
className="opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Pill className="w-5 h-5 text-secondary-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-700">{med.name}</h3>
|
||||
<p className="text-sm text-secondary-400">Inactive</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface MedicationCardProps {
|
||||
status: MedicationDueStatus
|
||||
now: Date
|
||||
onTake: () => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function MedicationCard({ status, now, onTake, onClick }: MedicationCardProps) {
|
||||
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
||||
|
||||
const getTimeLabel = () => {
|
||||
if (isOverdue && nextDueAt) {
|
||||
return formatTimeUntil(nextDueAt, now)
|
||||
}
|
||||
if (isPRN) {
|
||||
if (prnAvailable) {
|
||||
return 'Available now'
|
||||
}
|
||||
if (prnAvailableAt) {
|
||||
return `Available ${formatTimeUntil(prnAvailableAt, now)}`
|
||||
}
|
||||
return 'As needed'
|
||||
}
|
||||
if (nextDueAt) {
|
||||
return formatTimeUntil(nextDueAt, now)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const canTake = !isPRN || prnAvailable
|
||||
|
||||
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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
||||
<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()}
|
||||
{isPRN && ' • PRN'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTake()
|
||||
}}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={!canTake}
|
||||
>
|
||||
Taken
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
289
src/app/(app)/notes/page.tsx
Normal file
289
src/app/(app)/notes/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { toZonedTime } from 'date-fns-tz'
|
||||
import {
|
||||
Plus,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, createLocalNote, markQuestionAsked } from '@/lib/sync'
|
||||
import { Card, Button, LoadingState, EmptyState, Modal, Textarea, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function NotesPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [showNewNote, setShowNewNote] = useState(false)
|
||||
const [noteType, setNoteType] = useState<'QUESTION' | 'GENERAL'>('GENERAL')
|
||||
const [noteContent, setNoteContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const notes = useLiveQuery(
|
||||
() =>
|
||||
db.notes
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((n) => !n.deletedAt)
|
||||
.reverse()
|
||||
.sortBy('createdAt'),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const questions = notes?.filter((n) => n.type === 'QUESTION') || []
|
||||
const generalNotes = notes?.filter((n) => n.type === 'GENERAL') || []
|
||||
const unansweredQuestions = questions.filter((q) => !q.askedAt)
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!noteContent.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createLocalNote(currentWorkspace.id, {
|
||||
type: noteType,
|
||||
content: noteContent.trim(),
|
||||
})
|
||||
await refreshData()
|
||||
setNoteContent('')
|
||||
setShowNewNote(false)
|
||||
showToast(noteType === 'QUESTION' ? 'Question added' : 'Note added', 'success')
|
||||
} catch {
|
||||
showToast('Failed to add note', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAsked = useCallback(
|
||||
async (noteId: string) => {
|
||||
const note = notes?.find((n) => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
try {
|
||||
await markQuestionAsked(note)
|
||||
await refreshData()
|
||||
showToast('Marked as asked', 'success')
|
||||
} catch {
|
||||
showToast('Failed to update', 'error')
|
||||
}
|
||||
},
|
||||
[notes, refreshData]
|
||||
)
|
||||
|
||||
if (!notes) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Notes"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add note',
|
||||
onClick: () => setShowNewNote(true),
|
||||
}}
|
||||
/>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading notes..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Notes"
|
||||
rightAction={{
|
||||
icon: <Plus className="w-6 h-6 text-secondary-700" />,
|
||||
label: 'Add note',
|
||||
onClick: () => setShowNewNote(true),
|
||||
}}
|
||||
/>
|
||||
<PageContainer className="pt-4">
|
||||
{/* Quick links */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => router.push('/notes/questions')}
|
||||
className="flex-1 flex items-center gap-2 p-3 bg-amber-50 rounded-card border border-amber-100 hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5 text-amber-600" />
|
||||
<span className="font-medium text-amber-800">
|
||||
Questions ({unansweredQuestions.length})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNoteType('QUESTION')
|
||||
setShowNewNote(true)
|
||||
}}
|
||||
className="flex items-center gap-2 p-3 bg-primary-50 rounded-card border border-primary-100 hover:bg-primary-100 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-primary-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<EmptyState
|
||||
type="notes"
|
||||
title="No notes yet"
|
||||
description="Add questions for your doctor or general notes."
|
||||
action={{
|
||||
label: 'Add Note',
|
||||
onClick: () => setShowNewNote(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Questions for doctor */}
|
||||
{questions.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-secondary-600">
|
||||
Questions for Doctor
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => router.push('/notes/questions')}
|
||||
className="text-sm text-primary-600 font-medium"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{questions.slice(0, 3).map((note) => (
|
||||
<Card key={note.id} padding="sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
note.askedAt ? 'bg-green-100' : 'bg-amber-100'
|
||||
}`}
|
||||
>
|
||||
{note.askedAt ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<HelpCircle className="w-4 h-4 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-secondary-800 ${
|
||||
note.askedAt ? 'line-through opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
{!note.askedAt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkAsked(note.id)}
|
||||
>
|
||||
Asked
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* General notes */}
|
||||
{generalNotes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
General Notes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{generalNotes.map((note) => (
|
||||
<Card key={note.id} padding="sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-secondary-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<FileText className="w-4 h-4 text-secondary-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-secondary-800">{note.content}</p>
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
{format(
|
||||
toZonedTime(parseISO(note.createdAt || ''), TIMEZONE),
|
||||
'MMM d, h:mm a'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New note modal */}
|
||||
<Modal
|
||||
isOpen={showNewNote}
|
||||
onClose={() => {
|
||||
setShowNewNote(false)
|
||||
setNoteContent('')
|
||||
}}
|
||||
title={noteType === 'QUESTION' ? 'New Question' : 'New Note'}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setNoteType('GENERAL')}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
noteType === 'GENERAL'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
General Note
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNoteType('QUESTION')}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
noteType === 'QUESTION'
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
Question
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={noteContent}
|
||||
onChange={(e) => setNoteContent(e.target.value)}
|
||||
placeholder={
|
||||
noteType === 'QUESTION'
|
||||
? 'What do you want to ask the doctor?'
|
||||
: 'Write your note...'
|
||||
}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleAddNote}
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={!noteContent.trim()}
|
||||
>
|
||||
Add {noteType === 'QUESTION' ? 'Question' : 'Note'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
src/app/(app)/notes/questions/page.tsx
Normal file
158
src/app/(app)/notes/questions/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
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 { Card, Button, LoadingState, EmptyState, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function QuestionsPage() {
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
|
||||
const questions = useLiveQuery(
|
||||
() =>
|
||||
db.notes
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((n) => n.type === 'QUESTION' && !n.deletedAt)
|
||||
.reverse()
|
||||
.sortBy('createdAt'),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const unanswered = questions?.filter((q) => !q.askedAt) || []
|
||||
const answered = questions?.filter((q) => q.askedAt) || []
|
||||
|
||||
const handleMarkAsked = useCallback(
|
||||
async (noteId: string) => {
|
||||
const note = questions?.find((n) => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
try {
|
||||
await markQuestionAsked(note)
|
||||
await refreshData()
|
||||
showToast('Marked as asked', 'success')
|
||||
} catch {
|
||||
showToast('Failed to update', 'error')
|
||||
}
|
||||
},
|
||||
[questions, refreshData]
|
||||
)
|
||||
|
||||
const copyAllQuestions = () => {
|
||||
const text = unanswered.map((q) => `• ${q.content}`).join('\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
showToast('Questions copied', 'success')
|
||||
}
|
||||
|
||||
if (!questions) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Questions" showBack backHref="/notes" />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading questions..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Questions for Doctor" showBack backHref="/notes" />
|
||||
<PageContainer className="pt-4">
|
||||
{questions.length === 0 ? (
|
||||
<EmptyState
|
||||
type="notes"
|
||||
title="No questions"
|
||||
description="Add questions to ask your doctor at the next appointment."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Unanswered */}
|
||||
{unanswered.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-secondary-600">
|
||||
To Ask ({unanswered.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={copyAllQuestions}
|
||||
className="flex items-center gap-1 text-sm text-primary-600 font-medium"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy all
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{unanswered.map((note) => (
|
||||
<Card key={note.id}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-secondary-800">{note.content}</p>
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
Added{' '}
|
||||
{format(
|
||||
toZonedTime(parseISO(note.createdAt || ''), TIMEZONE),
|
||||
'MMM d'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={() => handleMarkAsked(note.id)}
|
||||
>
|
||||
Asked
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Answered */}
|
||||
{answered.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-400 mb-3">
|
||||
Asked ({answered.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{answered.map((note) => (
|
||||
<Card key={note.id} className="opacity-60">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-secondary-600 line-through">{note.content}</p>
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
Asked{' '}
|
||||
{format(
|
||||
toZonedTime(parseISO(note.askedAt || ''), TIMEZONE),
|
||||
'MMM d'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
86
src/app/(app)/provider.tsx
Normal file
86
src/app/(app)/provider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { startAutoSync, stopAutoSync, sync } from '@/lib/sync'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
clinicPhone: string | null
|
||||
emergencyPhone: string | null
|
||||
largeTextMode: boolean
|
||||
}
|
||||
|
||||
interface AppContextType {
|
||||
user: User
|
||||
workspaces: Workspace[]
|
||||
currentWorkspace: Workspace
|
||||
setCurrentWorkspaceId: (id: string) => void
|
||||
refreshData: () => Promise<void>
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null)
|
||||
|
||||
export function useApp() {
|
||||
const context = useContext(AppContext)
|
||||
if (!context) {
|
||||
throw new Error('useApp must be used within AppProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface AppProviderProps {
|
||||
children: React.ReactNode
|
||||
user: User
|
||||
workspaces: Workspace[]
|
||||
initialWorkspaceId: string
|
||||
}
|
||||
|
||||
export function AppProvider({
|
||||
children,
|
||||
user,
|
||||
workspaces,
|
||||
initialWorkspaceId,
|
||||
}: AppProviderProps) {
|
||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useState(initialWorkspaceId)
|
||||
|
||||
const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0]
|
||||
|
||||
// Start auto-sync when workspace changes
|
||||
useEffect(() => {
|
||||
if (currentWorkspaceId) {
|
||||
startAutoSync(currentWorkspaceId)
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopAutoSync()
|
||||
}
|
||||
}, [currentWorkspaceId])
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
if (currentWorkspaceId) {
|
||||
await sync(currentWorkspaceId)
|
||||
}
|
||||
}, [currentWorkspaceId])
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
user,
|
||||
workspaces,
|
||||
currentWorkspace,
|
||||
setCurrentWorkspaceId,
|
||||
refreshData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
84
src/app/(app)/settings/disclaimer/page.tsx
Normal file
84
src/app/(app)/settings/disclaimer/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { Shield, Phone, AlertTriangle } from 'lucide-react'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
|
||||
export default function DisclaimerPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Disclaimer" showBack backHref="/settings" />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-secondary-900">
|
||||
Important Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-secondary-700">
|
||||
<p>
|
||||
<strong>Next Step is a tracking and organizational tool only.</strong> It is
|
||||
designed to help you and your family keep track of appointments, medications,
|
||||
and notes during your healthcare journey.
|
||||
</p>
|
||||
|
||||
<div className="bg-red-50 border border-red-100 rounded-card p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-800">
|
||||
This app does not provide medical advice.
|
||||
</p>
|
||||
<p className="text-red-700 text-sm mt-1">
|
||||
Never use this app to make medical decisions. Always consult your
|
||||
healthcare team for any questions about your treatment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The medication reminders in this app are for tracking purposes only. They are
|
||||
not a substitute for professional medical advice, and the app does not verify
|
||||
the accuracy of medication schedules.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>If you experience a medical emergency:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Call 000 (Australia) or your local emergency services</li>
|
||||
<li>Go to your nearest emergency department</li>
|
||||
<li>Contact your healthcare team directly</li>
|
||||
</ul>
|
||||
|
||||
<div className="bg-primary-50 border border-primary-100 rounded-card p-4">
|
||||
<div className="flex gap-3">
|
||||
<Phone className="w-5 h-5 text-primary-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-primary-800">
|
||||
Questions about your treatment?
|
||||
</p>
|
||||
<p className="text-primary-700 text-sm mt-1">
|
||||
Use the "Call Clinic" button on the Today screen to contact your
|
||||
healthcare team directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-500 pt-4 border-t border-border">
|
||||
By using Next Step, you acknowledge that this application is for organizational
|
||||
purposes only and does not replace professional medical advice, diagnosis, or
|
||||
treatment.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
417
src/app/(app)/settings/page.tsx
Normal file
417
src/app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Phone,
|
||||
Users,
|
||||
Type,
|
||||
Download,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db } from '@/lib/sync'
|
||||
import { Card, Button, Input, Modal, showToast } from '@/components/ui'
|
||||
import { Header, PageContainer } from '@/components/layout/header'
|
||||
import { useApp } from '../provider'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const { user, currentWorkspace, workspaces, refreshData } = useApp()
|
||||
|
||||
const [showPhoneEdit, setShowPhoneEdit] = useState(false)
|
||||
const [clinicPhone, setClinicPhone] = useState(currentWorkspace.clinicPhone || '')
|
||||
const [emergencyPhone, setEmergencyPhone] = useState(currentWorkspace.emergencyPhone || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [inviteUrl, setInviteUrl] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<'EDITOR' | 'VIEWER'>('VIEWER')
|
||||
|
||||
// Get workspace from IndexedDB for large text mode
|
||||
const workspace = useLiveQuery(
|
||||
() => db.workspaces.get(currentWorkspace.id),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const handleUpdatePhones = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clinicPhone: clinicPhone.trim() || null,
|
||||
emergencyPhone: emergencyPhone.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update')
|
||||
|
||||
await refreshData()
|
||||
setShowPhoneEdit(false)
|
||||
showToast('Phone numbers updated', 'success')
|
||||
} catch {
|
||||
showToast('Failed to save', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleLargeText = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
largeTextMode: !currentWorkspace.largeTextMode,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update')
|
||||
|
||||
await refreshData()
|
||||
window.location.reload()
|
||||
} catch {
|
||||
showToast('Failed to update', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
setInviteLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/invite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: inviteRole, expiresInDays: 7 }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error)
|
||||
|
||||
setInviteUrl(data.invite.url)
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : 'Failed to create invite', 'error')
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyInviteLink = () => {
|
||||
navigator.clipboard.writeText(inviteUrl)
|
||||
showToast('Link copied!', 'success')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login')
|
||||
router.refresh()
|
||||
} catch {
|
||||
showToast('Failed to log out', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportJSON = async () => {
|
||||
try {
|
||||
// Fetch all data
|
||||
const [appointments, medications, notes, doseLogs] = await Promise.all([
|
||||
db.appointments.where('workspaceId').equals(currentWorkspace.id).toArray(),
|
||||
db.medications.where('workspaceId').equals(currentWorkspace.id).toArray(),
|
||||
db.notes.where('workspaceId').equals(currentWorkspace.id).toArray(),
|
||||
db.doseLogs.where('workspaceId').equals(currentWorkspace.id).toArray(),
|
||||
])
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
workspace: currentWorkspace.name,
|
||||
appointments: appointments.filter((a) => !a.deletedAt),
|
||||
medications: medications.filter((m) => !m.deletedAt),
|
||||
notes: notes.filter((n) => !n.deletedAt),
|
||||
doseLogs: doseLogs.filter((d) => !d.undoneAt),
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `nextstep-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast('Data exported', 'success')
|
||||
} catch {
|
||||
showToast('Export failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Settings" />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Workspace info */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Workspace
|
||||
</h2>
|
||||
<Card>
|
||||
<p className="font-semibold text-secondary-900">{currentWorkspace.name}</p>
|
||||
<p className="text-sm text-secondary-500 capitalize mt-1">
|
||||
You're the {currentWorkspace.role.toLowerCase()}
|
||||
</p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact numbers */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Contact Numbers
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => setShowPhoneEdit(true)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Phone className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Clinic</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{currentWorkspace.clinicPhone || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setShowPhoneEdit(true)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Phone className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Emergency Contact</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{currentWorkspace.emergencyPhone || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Family members */}
|
||||
{currentWorkspace.role === 'OWNER' && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Family Access
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => setShowInvite(true)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Users className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Invite Family Member</p>
|
||||
<p className="text-sm text-secondary-500">Share access to this workspace</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Accessibility */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">
|
||||
Accessibility
|
||||
</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={handleToggleLargeText}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Type className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Large Text</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
{currentWorkspace.largeTextMode ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-12 h-7 rounded-full transition-colors ${
|
||||
currentWorkspace.largeTextMode ? 'bg-primary-500' : 'bg-secondary-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-sm mt-1 transition-transform ${
|
||||
currentWorkspace.largeTextMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Data */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Data</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={handleExportJSON}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Download className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Export Data</p>
|
||||
<p className="text-sm text-secondary-500">Download as JSON</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Legal */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">About</h2>
|
||||
<Card padding="none">
|
||||
<button
|
||||
onClick={() => router.push('/settings/disclaimer')}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-secondary-500" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="font-medium text-secondary-900">Disclaimer</p>
|
||||
<p className="text-sm text-secondary-500">Important information</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-300" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Account */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-secondary-600 mb-3">Account</h2>
|
||||
<Card>
|
||||
<p className="text-secondary-700">{user.name}</p>
|
||||
<p className="text-sm text-secondary-500">{user.email}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-4 text-red-600 hover:bg-red-50"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</Card>
|
||||
</section>
|
||||
</PageContainer>
|
||||
|
||||
{/* Phone edit modal */}
|
||||
<Modal
|
||||
isOpen={showPhoneEdit}
|
||||
onClose={() => setShowPhoneEdit(false)}
|
||||
title="Contact Numbers"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<Input
|
||||
label="Emergency Contact"
|
||||
type="tel"
|
||||
value={emergencyPhone}
|
||||
onChange={(e) => setEmergencyPhone(e.target.value)}
|
||||
placeholder="e.g., 0412 345 678"
|
||||
/>
|
||||
<Button onClick={handleUpdatePhones} fullWidth loading={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invite modal */}
|
||||
<Modal
|
||||
isOpen={showInvite}
|
||||
onClose={() => {
|
||||
setShowInvite(false)
|
||||
setInviteUrl('')
|
||||
}}
|
||||
title="Invite Family Member"
|
||||
>
|
||||
{!inviteUrl ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Create an invite link to share with a family member. They'll be able to
|
||||
view and help manage {currentWorkspace.name}.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
||||
Permission Level
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setInviteRole('VIEWER')}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
inviteRole === 'VIEWER'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
Viewer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInviteRole('EDITOR')}
|
||||
className={`flex-1 py-2 px-3 rounded-button text-sm font-medium transition-colors ${
|
||||
inviteRole === 'EDITOR'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-muted text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
{inviteRole === 'VIEWER'
|
||||
? 'Can view everything but not make changes'
|
||||
: 'Can add appointments, log doses, and add notes'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreateInvite} fullWidth loading={inviteLoading}>
|
||||
Create Invite Link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary-600">
|
||||
Share this link with your family member. It expires in 7 days.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted p-3 rounded-button break-all text-sm text-secondary-700">
|
||||
{inviteUrl}
|
||||
</div>
|
||||
|
||||
<Button onClick={copyInviteLink} fullWidth>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
397
src/app/(app)/today/page.tsx
Normal file
397
src/app/(app)/today/page.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
'use client'
|
||||
|
||||
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 { useLiveQuery } from 'dexie-react-hooks'
|
||||
|
||||
import { db, logDose, undoDose } from '@/lib/sync'
|
||||
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 { useApp } from '../provider'
|
||||
|
||||
const TIMEZONE = 'Australia/Perth'
|
||||
|
||||
export default function TodayPage() {
|
||||
const router = useRouter()
|
||||
const { currentWorkspace, refreshData } = useApp()
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
const [quickNote, setQuickNote] = useState('')
|
||||
const [isAddingNote, setIsAddingNote] = useState(false)
|
||||
|
||||
// Update time every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Fetch data from IndexedDB
|
||||
const appointments = useLiveQuery(
|
||||
() =>
|
||||
db.appointments
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((a) => !a.deletedAt && new Date(a.datetime) >= now)
|
||||
.sortBy('datetime'),
|
||||
[currentWorkspace.id, now]
|
||||
)
|
||||
|
||||
const medications = useLiveQuery(
|
||||
() =>
|
||||
db.medications
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.and((m) => m.active && !m.deletedAt)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const doseLogs = useLiveQuery(
|
||||
() =>
|
||||
db.doseLogs
|
||||
.where('workspaceId')
|
||||
.equals(currentWorkspace.id)
|
||||
.toArray(),
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
// Calculate medication due statuses
|
||||
const [medStatuses, setMedStatuses] = useState<MedicationDueStatus[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (medications && doseLogs) {
|
||||
const meds = medications.map((m) => ({
|
||||
...m,
|
||||
scheduleData: m.scheduleData as Medication['scheduleData'],
|
||||
startDate: m.startDate ? new Date(m.startDate) : null,
|
||||
endDate: m.endDate ? new Date(m.endDate) : null,
|
||||
})) as Medication[]
|
||||
|
||||
const logs = doseLogs.map((d) => ({
|
||||
...d,
|
||||
takenAt: new Date(d.takenAt),
|
||||
undoneAt: d.undoneAt ? new Date(d.undoneAt) : null,
|
||||
})) as DoseLog[]
|
||||
|
||||
const statuses = calculateAllMedicationsDue(meds, now, logs)
|
||||
setMedStatuses(statuses)
|
||||
}
|
||||
}, [medications, doseLogs, now])
|
||||
|
||||
// Get next appointment
|
||||
const nextAppointment = appointments?.[0]
|
||||
|
||||
// Get meds due soon (due within 2 hours or overdue)
|
||||
const medsDueSoon = medStatuses
|
||||
.filter((s) => {
|
||||
if (s.isOverdue) return true
|
||||
if (s.isPRN && s.prnAvailable) return true
|
||||
if (s.nextDueAt) {
|
||||
const minutesUntil = (s.nextDueAt.getTime() - now.getTime()) / 1000 / 60
|
||||
return minutesUntil <= 120
|
||||
}
|
||||
return false
|
||||
})
|
||||
.slice(0, 5)
|
||||
|
||||
const handleTakeMed = useCallback(
|
||||
async (status: MedicationDueStatus) => {
|
||||
try {
|
||||
const doseLog = await logDose(
|
||||
currentWorkspace.id,
|
||||
status.medication.id,
|
||||
{ id: status.medication.id, name: status.medication.name }
|
||||
)
|
||||
|
||||
showUndoToast(`Took ${status.medication.name}`, async () => {
|
||||
await undoDose(doseLog)
|
||||
showToast('Dose undone', 'info')
|
||||
})
|
||||
} catch {
|
||||
showToast('Failed to log dose', 'error')
|
||||
}
|
||||
},
|
||||
[currentWorkspace.id]
|
||||
)
|
||||
|
||||
const handleAddQuickNote = async () => {
|
||||
if (!quickNote.trim()) return
|
||||
|
||||
setIsAddingNote(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${currentWorkspace.id}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'GENERAL',
|
||||
content: quickNote.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add note')
|
||||
|
||||
setQuickNote('')
|
||||
showToast('Note added', 'success')
|
||||
await refreshData()
|
||||
} catch {
|
||||
showToast('Failed to add note', 'error')
|
||||
} finally {
|
||||
setIsAddingNote(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatAppointmentDate = (datetime: string) => {
|
||||
const date = toZonedTime(new Date(datetime), TIMEZONE)
|
||||
if (isToday(date)) return `Today at ${format(date, 'h:mm a')}`
|
||||
if (isTomorrow(date)) return `Tomorrow at ${format(date, 'h:mm a')}`
|
||||
return format(date, 'EEE, MMM d \'at\' h:mm a')
|
||||
}
|
||||
|
||||
if (!appointments || !medications) {
|
||||
return (
|
||||
<>
|
||||
<Header title="Today" />
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading your day..." />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Today" />
|
||||
<PageContainer className="pt-4 space-y-6">
|
||||
{/* Greeting */}
|
||||
<div className="mb-2">
|
||||
<p className="text-secondary-500 text-sm">
|
||||
{format(toZonedTime(now, TIMEZONE), 'EEEE, MMMM d')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<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>
|
||||
<p className="font-medium text-primary-800">Call Clinic</p>
|
||||
<p className="text-sm text-primary-600">{currentWorkspace.clinicPhone}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Next Appointment */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Next Appointment</h2>
|
||||
<button
|
||||
onClick={() => router.push('/appointments')}
|
||||
className="text-sm text-primary-600 font-medium flex items-center"
|
||||
>
|
||||
View all
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nextAppointment ? (
|
||||
<Card
|
||||
className="card-appointment"
|
||||
onClick={() => router.push(`/appointments/${nextAppointment.id}`)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900 truncate">
|
||||
{nextAppointment.title}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 flex items-center gap-1 mt-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatAppointmentDate(nextAppointment.datetime)}
|
||||
</p>
|
||||
{nextAppointment.location && (
|
||||
<p className="text-sm text-secondary-500 flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="truncate">{nextAppointment.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-secondary-400" />
|
||||
</div>
|
||||
{nextAppointment.mapUrl && (
|
||||
<a
|
||||
href={nextAppointment.mapUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-sm text-primary-600 font-medium hover:text-primary-700"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
Open in Maps
|
||||
</a>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card variant="outline" className="text-center py-6">
|
||||
<Calendar className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
||||
<p className="text-secondary-500">No upcoming appointments</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => router.push('/appointments/new')}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add one
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Meds Due */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">Medications</h2>
|
||||
<button
|
||||
onClick={() => router.push('/meds')}
|
||||
className="text-sm text-primary-600 font-medium flex items-center"
|
||||
>
|
||||
View all
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{medsDueSoon.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{medsDueSoon.map((status) => (
|
||||
<MedicationCard
|
||||
key={status.medication.id}
|
||||
status={status}
|
||||
now={now}
|
||||
onTake={() => handleTakeMed(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : medications.length > 0 ? (
|
||||
<Card variant="outline" className="text-center py-6">
|
||||
<Pill className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
|
||||
<p className="text-secondary-500">All caught up! No meds due soon.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
type="medications"
|
||||
title="No medications"
|
||||
description="Add medications to track when to take them."
|
||||
action={{
|
||||
label: 'Add Medication',
|
||||
onClick: () => router.push('/meds/new'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Quick Note */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-secondary-900 mb-3">Quick Note</h2>
|
||||
<Card padding="sm">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={quickNote}
|
||||
onChange={(e) => setQuickNote(e.target.value)}
|
||||
placeholder="Jot down a thought..."
|
||||
className="flex-1 px-3 py-2.5 border border-border rounded-button text-base focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && quickNote.trim()) {
|
||||
handleAddQuickNote()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddQuickNote}
|
||||
disabled={!quickNote.trim() || isAddingNote}
|
||||
loading={isAddingNote}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface MedicationCardProps {
|
||||
status: MedicationDueStatus
|
||||
now: Date
|
||||
onTake: () => void
|
||||
}
|
||||
|
||||
function MedicationCard({ status, now, onTake }: MedicationCardProps) {
|
||||
const { medication, isOverdue, isPRN, prnAvailable, prnAvailableAt, nextDueAt } = status
|
||||
|
||||
const getTimeLabel = () => {
|
||||
if (isOverdue && nextDueAt) {
|
||||
return formatTimeUntil(nextDueAt, now)
|
||||
}
|
||||
if (isPRN) {
|
||||
if (prnAvailable) {
|
||||
return 'Available now'
|
||||
}
|
||||
if (prnAvailableAt) {
|
||||
return `Available ${formatTimeUntil(prnAvailableAt, now)}`
|
||||
}
|
||||
return 'As needed'
|
||||
}
|
||||
if (nextDueAt) {
|
||||
return formatTimeUntil(nextDueAt, now)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const canTake = !isPRN || prnAvailable
|
||||
|
||||
return (
|
||||
<Card className={isOverdue ? 'overdue' : ''}>
|
||||
<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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-secondary-900">{medication.name}</h3>
|
||||
<p className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-secondary-500'}`}>
|
||||
{getTimeLabel()}
|
||||
{isPRN && ' • As needed'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTake()
|
||||
}}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={!canTake}
|
||||
>
|
||||
Taken
|
||||
</Button>
|
||||
</div>
|
||||
{medication.instructions && (
|
||||
<p className="text-sm text-secondary-500 mt-2 ml-13">
|
||||
{medication.instructions}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
96
src/app/api/auth/login/route.ts
Normal file
96
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import {
|
||||
verifyPassword,
|
||||
createSession,
|
||||
getSessionCookieConfig,
|
||||
checkLoginRateLimit,
|
||||
recordLoginAttempt,
|
||||
} from '@/lib/auth'
|
||||
import { loginSchema } from '@/lib/validation'
|
||||
import { withRateLimit } from '@/lib/auth/middleware'
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = loginSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, password } = result.data
|
||||
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = await checkLoginRateLimit(email.toLowerCase(), ipAddress)
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Too many failed attempts. Please try again in ${rateLimit.lockoutMinutes} minutes.`,
|
||||
},
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
await recordLoginAttempt(email.toLowerCase(), false, ipAddress)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await verifyPassword(user.passwordHash, password)
|
||||
if (!valid) {
|
||||
await recordLoginAttempt(email.toLowerCase(), false, ipAddress)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
await recordLoginAttempt(email.toLowerCase(), true, ipAddress)
|
||||
|
||||
// Create session
|
||||
const userAgent = req.headers.get('user-agent') || undefined
|
||||
const token = await createSession(user.id, userAgent, ipAddress)
|
||||
const cookieConfig = getSessionCookieConfig(token)
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
})
|
||||
|
||||
response.cookies.set(cookieConfig)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Login failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler)
|
||||
25
src/app/api/auth/logout/route.ts
Normal file
25
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (session) {
|
||||
await deleteSession(session.sessionId)
|
||||
}
|
||||
|
||||
const cookieConfig = getSessionCookieClearConfig()
|
||||
const response = NextResponse.json({ message: 'Logged out successfully' })
|
||||
response.cookies.set(cookieConfig)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
// Still clear the cookie even on error
|
||||
const cookieConfig = getSessionCookieClearConfig()
|
||||
const response = NextResponse.json({ message: 'Logged out' })
|
||||
response.cookies.set(cookieConfig)
|
||||
return response
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/me/route.ts
Normal file
46
src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user's workspaces
|
||||
const memberships = await prisma.workspaceMember.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
largeTextMode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
user: session.user,
|
||||
workspaces: memberships.map((m) => ({
|
||||
...m.workspace,
|
||||
role: m.role,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get user info' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
src/app/api/auth/register/route.ts
Normal file
71
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { hashPassword, createSession, getSessionCookieConfig } from '@/lib/auth'
|
||||
import { registerSchema } from '@/lib/validation'
|
||||
import { withRateLimit } from '@/lib/auth/middleware'
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = registerSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, password, name } = result.data
|
||||
|
||||
// Check if user exists
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An account with this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create user
|
||||
const passwordHash = await hashPassword(password)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
name,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create session
|
||||
const userAgent = req.headers.get('user-agent') || undefined
|
||||
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
|
||||
const token = await createSession(user.id, userAgent, ipAddress)
|
||||
const cookieConfig = getSessionCookieConfig(token)
|
||||
|
||||
const response = NextResponse.json({
|
||||
user,
|
||||
message: 'Account created successfully',
|
||||
})
|
||||
|
||||
response.cookies.set(cookieConfig)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Registration failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler)
|
||||
25
src/app/api/health/route.ts
Normal file
25
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check database connectivity
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Database connection failed',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
175
src/app/api/invite/[token]/route.ts
Normal file
175
src/app/api/invite/[token]/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRateLimit } from '@/lib/auth/middleware'
|
||||
|
||||
// GET /api/invite/[token] - Get invite details (public)
|
||||
async function getHandler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
try {
|
||||
const { token } = await params
|
||||
|
||||
const invite = await prisma.inviteToken.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!invite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invite not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invite.usedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This invite has already been used' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invite.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This invite has expired' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
invite: {
|
||||
workspaceName: invite.workspace.name,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get invite error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get invite' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/invite/[token] - Accept invite (requires auth)
|
||||
async function postHandler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be logged in to accept an invite' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { token } = await params
|
||||
|
||||
const invite = await prisma.inviteToken.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!invite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invite not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invite.usedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This invite has already been used' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
if (invite.expiresAt < new Date()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This invite has expired' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existingMember = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: invite.workspaceId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You are already a member of this workspace' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Accept invite in a transaction
|
||||
const [member] = await prisma.$transaction([
|
||||
prisma.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: invite.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
}),
|
||||
prisma.inviteToken.update({
|
||||
where: { id: invite.id },
|
||||
data: {
|
||||
usedAt: new Date(),
|
||||
usedById: session.user.id,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId: invite.workspaceId,
|
||||
userId: session.user.id,
|
||||
action: 'JOIN',
|
||||
entityType: 'WORKSPACE',
|
||||
entityId: invite.workspaceId,
|
||||
details: { role: invite.role, inviteToken: token },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: {
|
||||
id: invite.workspace.id,
|
||||
name: invite.workspace.name,
|
||||
role: member.role,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Accept invite error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to accept invite' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withRateLimit(getHandler)
|
||||
export const POST = withRateLimit(postHandler)
|
||||
315
src/app/api/sync/route.ts
Normal file
315
src/app/api/sync/route.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
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 { syncQuerySchema, syncOpsSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/sync - Get changes since cursor
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const result = syncQuerySchema.safeParse({
|
||||
workspaceId: searchParams.get('workspaceId'),
|
||||
since: searchParams.get('since'),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId, since = 0 } = result.data
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const sinceDate = new Date(since)
|
||||
|
||||
// Fetch all changed entities
|
||||
const [appointments, medications, notes, doseLogs, workspace] = await Promise.all([
|
||||
prisma.appointment.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.note.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.doseLog.findMany({
|
||||
where: { workspaceId, syncedAt: { gt: sinceDate } },
|
||||
include: {
|
||||
medication: { select: { id: true, name: true } },
|
||||
loggedBy: { select: { id: true, name: true } },
|
||||
undoneBy: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
quietHoursStart: true,
|
||||
quietHoursEnd: true,
|
||||
largeTextMode: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Calculate new cursor (latest syncedAt timestamp)
|
||||
let cursor = since
|
||||
const allItems = [...appointments, ...medications, ...notes, ...doseLogs]
|
||||
for (const item of allItems) {
|
||||
const itemTime = (item as { syncedAt: Date }).syncedAt.getTime()
|
||||
if (itemTime > cursor) {
|
||||
cursor = itemTime
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace,
|
||||
appointments,
|
||||
medications,
|
||||
notes,
|
||||
doseLogs,
|
||||
cursor,
|
||||
hasConflicts: false, // For now, always false - client handles conflicts
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync get error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/sync - Upload operations from client outbox
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = syncOpsSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { workspaceId, ops } = result.data
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id, ['OWNER', 'EDITOR'])
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const results: { opId: string; success: boolean; entityId?: string; error?: string }[] = []
|
||||
|
||||
for (const op of ops) {
|
||||
try {
|
||||
switch (op.type) {
|
||||
case 'CREATE': {
|
||||
if (op.entityType === 'APPOINTMENT' && op.data) {
|
||||
const appt = await prisma.appointment.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
title: op.data.title as string,
|
||||
datetime: new Date(op.data.datetime as string),
|
||||
location: (op.data.location as string) || null,
|
||||
mapUrl: (op.data.mapUrl as string) || null,
|
||||
notes: (op.data.notes as string) || null,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: appt.id })
|
||||
} else if (op.entityType === 'NOTE' && op.data) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: op.data.type as 'QUESTION' | 'GENERAL',
|
||||
content: op.data.content as string,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: note.id })
|
||||
} else {
|
||||
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'UPDATE': {
|
||||
if (!op.entityId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
||||
break
|
||||
}
|
||||
|
||||
if (op.entityType === 'APPOINTMENT' && op.data) {
|
||||
await prisma.appointment.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
...(op.data.title && { title: op.data.title as string }),
|
||||
...(op.data.datetime && { datetime: new Date(op.data.datetime as string) }),
|
||||
...(op.data.location !== undefined && { location: op.data.location as string | null }),
|
||||
...(op.data.mapUrl !== undefined && { mapUrl: op.data.mapUrl as string | null }),
|
||||
...(op.data.notes !== undefined && { notes: op.data.notes as string | null }),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: op.entityId })
|
||||
} else if (op.entityType === 'NOTE' && op.data) {
|
||||
await prisma.note.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
...(op.data.content && { content: op.data.content as string }),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: op.entityId })
|
||||
} else {
|
||||
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
if (!op.entityId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
||||
break
|
||||
}
|
||||
|
||||
if (op.entityType === 'APPOINTMENT') {
|
||||
await prisma.appointment.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true })
|
||||
} else if (op.entityType === 'NOTE') {
|
||||
await prisma.note.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true })
|
||||
} else {
|
||||
results.push({ opId: op.id, success: false, error: 'Unsupported entity type' })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'TAKE_DOSE': {
|
||||
if (!op.data?.medicationId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing medicationId' })
|
||||
break
|
||||
}
|
||||
|
||||
const doseLog = await prisma.doseLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
medicationId: op.data.medicationId as string,
|
||||
takenAt: op.data.takenAt ? new Date(op.data.takenAt as string) : new Date(),
|
||||
loggedById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true, entityId: doseLog.id })
|
||||
break
|
||||
}
|
||||
|
||||
case 'UNDO_DOSE': {
|
||||
if (!op.entityId) {
|
||||
results.push({ opId: op.id, success: false, error: 'Missing entityId' })
|
||||
break
|
||||
}
|
||||
|
||||
await prisma.doseLog.update({
|
||||
where: { id: op.entityId },
|
||||
data: {
|
||||
undoneAt: new Date(),
|
||||
undoneById: req.session.user.id,
|
||||
},
|
||||
})
|
||||
results.push({ opId: op.id, success: true })
|
||||
break
|
||||
}
|
||||
|
||||
case 'MARK_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: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
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' })
|
||||
}
|
||||
} catch (opError) {
|
||||
console.error('Op error:', opError)
|
||||
results.push({ opId: op.id, success: false, error: 'Operation failed' })
|
||||
}
|
||||
}
|
||||
|
||||
// Get new cursor
|
||||
const cursor = Date.now()
|
||||
|
||||
return NextResponse.json({ results, cursor })
|
||||
} catch (error) {
|
||||
console.error('Sync post error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
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 { appointmentSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/appointments/[appointmentId]
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.findFirst({
|
||||
where: { id: appointmentId, workspaceId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!appointment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Appointment not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ appointment })
|
||||
} catch (error) {
|
||||
console.error('Get appointment error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get appointment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/appointments/[appointmentId]
|
||||
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, appointmentId } = 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.appointment.findFirst({
|
||||
where: { id: appointmentId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Appointment not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = appointmentSchema.partial().safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
...result.data,
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
}
|
||||
|
||||
if (result.data.datetime) {
|
||||
updateData.datetime = new Date(result.data.datetime)
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: appointmentId },
|
||||
data: updateData,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: appointmentId,
|
||||
details: result.data,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ appointment })
|
||||
} catch (error) {
|
||||
console.error('Update appointment error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update appointment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/appointments/[appointmentId] (soft delete)
|
||||
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, appointmentId } = 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.appointment.findFirst({
|
||||
where: { id: appointmentId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Appointment not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.appointment.update({
|
||||
where: { id: appointmentId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: appointmentId,
|
||||
details: { title: existing.title },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Appointment deleted' })
|
||||
} catch (error) {
|
||||
console.error('Delete appointment error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete appointment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
116
src/app/api/workspaces/[id]/appointments/route.ts
Normal file
116
src/app/api/workspaces/[id]/appointments/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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 { appointmentSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/appointments - List appointments
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const includeDeleted = searchParams.get('includeDeleted') === 'true'
|
||||
const fromDate = searchParams.get('from')
|
||||
const toDate = searchParams.get('to')
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(includeDeleted ? {} : { deletedAt: null }),
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
where.datetime = { ...(where.datetime as object || {}), gte: new Date(fromDate) }
|
||||
}
|
||||
if (toDate) {
|
||||
where.datetime = { ...(where.datetime as object || {}), lte: new Date(toDate) }
|
||||
}
|
||||
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where,
|
||||
orderBy: { datetime: 'asc' },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ appointments })
|
||||
} catch (error) {
|
||||
console.error('List appointments error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list appointments' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/appointments - Create appointment
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 = appointmentSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
title: result.data.title,
|
||||
datetime: new Date(result.data.datetime),
|
||||
location: result.data.location || null,
|
||||
mapUrl: result.data.mapUrl || null,
|
||||
notes: result.data.notes || null,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: appointment.id,
|
||||
details: { title: appointment.title },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ appointment }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create appointment error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create appointment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
221
src/app/api/workspaces/[id]/doses/route.ts
Normal file
221
src/app/api/workspaces/[id]/doses/route.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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 { doseLogSchema, undoDoseSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/doses - List dose logs
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 medicationId = searchParams.get('medicationId')
|
||||
const fromDate = searchParams.get('from')
|
||||
const toDate = searchParams.get('to')
|
||||
const includeUndone = searchParams.get('includeUndone') === 'true'
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(medicationId ? { medicationId } : {}),
|
||||
...(includeUndone ? {} : { undoneAt: null }),
|
||||
}
|
||||
|
||||
if (fromDate || toDate) {
|
||||
where.takenAt = {}
|
||||
if (fromDate) (where.takenAt as Record<string, unknown>).gte = new Date(fromDate)
|
||||
if (toDate) (where.takenAt as Record<string, unknown>).lte = new Date(toDate)
|
||||
}
|
||||
|
||||
const doseLogs = await prisma.doseLog.findMany({
|
||||
where,
|
||||
orderBy: { takenAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
medication: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
loggedBy: { select: { id: true, name: true } },
|
||||
undoneBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ doseLogs })
|
||||
} catch (error) {
|
||||
console.error('List dose logs error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list dose logs' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/doses - Log a dose (Take medication)
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 = doseLogSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify medication exists and belongs to workspace
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: {
|
||||
id: result.data.medicationId,
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!medication) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Medication not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const takenAt = result.data.takenAt ? new Date(result.data.takenAt) : new Date()
|
||||
|
||||
const doseLog = await prisma.doseLog.create({
|
||||
data: {
|
||||
medicationId: result.data.medicationId,
|
||||
workspaceId,
|
||||
takenAt,
|
||||
loggedById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
medication: { select: { id: true, name: true } },
|
||||
loggedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'TAKE_DOSE',
|
||||
entityType: 'DOSE_LOG',
|
||||
entityId: doseLog.id,
|
||||
details: { medicationName: medication.name, takenAt: takenAt.toISOString() },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ doseLog }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Log dose error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to log dose' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/doses - Undo a dose
|
||||
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 = undoDoseSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const doseLog = await prisma.doseLog.findFirst({
|
||||
where: {
|
||||
id: result.data.doseLogId,
|
||||
workspaceId,
|
||||
undoneAt: null,
|
||||
},
|
||||
include: {
|
||||
medication: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!doseLog) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Dose log not found or already undone' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if within undo window (5 minutes)
|
||||
const minutesSinceDose = (Date.now() - doseLog.takenAt.getTime()) / 1000 / 60
|
||||
if (minutesSinceDose > 5) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Undo window has expired (5 minutes)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.doseLog.update({
|
||||
where: { id: doseLog.id },
|
||||
data: {
|
||||
undoneAt: new Date(),
|
||||
undoneById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
medication: { select: { id: true, name: true } },
|
||||
loggedBy: { select: { id: true, name: true } },
|
||||
undoneBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'UNDO_DOSE',
|
||||
entityType: 'DOSE_LOG',
|
||||
entityId: doseLog.id,
|
||||
details: { medicationName: doseLog.medication.name },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ doseLog: updated })
|
||||
} catch (error) {
|
||||
console.error('Undo dose error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to undo dose' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
126
src/app/api/workspaces/[id]/invite/route.ts
Normal file
126
src/app/api/workspaces/[id]/invite/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { inviteSchema } from '@/lib/validation'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
// Helper to check workspace access
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
requiredRoles: string[] = ['OWNER']
|
||||
) {
|
||||
const member = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
},
|
||||
})
|
||||
|
||||
if (!member || !requiredRoles.includes(member.role)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return member
|
||||
}
|
||||
|
||||
// POST /api/workspaces/[id]/invite - Create invite token
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
// Only owners can create invites
|
||||
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
|
||||
if (!member) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace owners can create invites' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = inviteSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { role, expiresInDays } = result.data
|
||||
|
||||
const token = nanoid(32)
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInDays)
|
||||
|
||||
const invite = await prisma.inviteToken.create({
|
||||
data: {
|
||||
workspaceId: id,
|
||||
token,
|
||||
role,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Build invite URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const inviteUrl = `${baseUrl}/invite/${token}`
|
||||
|
||||
return NextResponse.json({
|
||||
invite: {
|
||||
token: invite.token,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
url: inviteUrl,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Create invite error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create invite' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/workspaces/[id]/invite - List active invites
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER'])
|
||||
if (!member) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only workspace owners can view invites' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const invites = await prisma.inviteToken.findMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
return NextResponse.json({
|
||||
invites: invites.map((i) => ({
|
||||
id: i.id,
|
||||
token: i.token,
|
||||
role: i.role,
|
||||
expiresAt: i.expiresAt,
|
||||
url: `${baseUrl}/invite/${i.token}`,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('List invites error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list invites' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
187
src/app/api/workspaces/[id]/medications/[medicationId]/route.ts
Normal file
187
src/app/api/workspaces/[id]/medications/[medicationId]/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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'
|
||||
|
||||
// GET /api/workspaces/[id]/medications/[medicationId]
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, medicationId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: { id: medicationId, workspaceId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
doseLogs: {
|
||||
where: { undoneAt: null },
|
||||
orderBy: { takenAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
loggedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!medication) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Medication not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ medication })
|
||||
} catch (error) {
|
||||
console.error('Get medication error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get medication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/medications/[medicationId]
|
||||
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, medicationId } = 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.medication.findFirst({
|
||||
where: { id: medicationId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Medication not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = medicationSchema.partial().safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
...result.data,
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
}
|
||||
|
||||
if (result.data.startDate !== undefined) {
|
||||
updateData.startDate = result.data.startDate ? new Date(result.data.startDate) : null
|
||||
}
|
||||
if (result.data.endDate !== undefined) {
|
||||
updateData.endDate = result.data.endDate ? new Date(result.data.endDate) : null
|
||||
}
|
||||
|
||||
const medication = await prisma.medication.update({
|
||||
where: { id: medicationId },
|
||||
data: updateData,
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'MEDICATION',
|
||||
entityId: medicationId,
|
||||
details: result.data,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ medication })
|
||||
} catch (error) {
|
||||
console.error('Update medication error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update medication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/medications/[medicationId] (soft delete)
|
||||
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, medicationId } = 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.medication.findFirst({
|
||||
where: { id: medicationId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Medication not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.medication.update({
|
||||
where: { id: medicationId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
active: false,
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'MEDICATION',
|
||||
entityId: medicationId,
|
||||
details: { name: existing.name },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Medication deleted' })
|
||||
} catch (error) {
|
||||
console.error('Delete medication error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete medication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
111
src/app/api/workspaces/[id]/medications/route.ts
Normal file
111
src/app/api/workspaces/[id]/medications/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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'
|
||||
|
||||
// GET /api/workspaces/[id]/medications - List medications
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const activeOnly = searchParams.get('activeOnly') !== 'false'
|
||||
const includeDeleted = searchParams.get('includeDeleted') === 'true'
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(includeDeleted ? {} : { deletedAt: null }),
|
||||
...(activeOnly ? { active: true } : {}),
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ medications })
|
||||
} catch (error) {
|
||||
console.error('List medications error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list medications' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/medications - Create medication
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 = medicationSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
name: result.data.name,
|
||||
instructions: result.data.instructions || null,
|
||||
scheduleType: result.data.scheduleType,
|
||||
scheduleData: result.data.scheduleData,
|
||||
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,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'MEDICATION',
|
||||
entityId: medication.id,
|
||||
details: { name: medication.name },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ medication }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create medication error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create medication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
196
src/app/api/workspaces/[id]/notes/[noteId]/route.ts
Normal file
196
src/app/api/workspaces/[id]/notes/[noteId]/route.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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 { noteSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/notes/[noteId]
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, noteId } = await params
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceId, req.session.user.id)
|
||||
if (!access) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, workspaceId },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ note })
|
||||
} catch (error) {
|
||||
console.error('Get note error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id]/notes/[noteId]
|
||||
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, noteId } = 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.note.findFirst({
|
||||
where: { id: noteId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// Handle marking question as asked
|
||||
if (body.markAsked === true && existing.type === 'QUESTION') {
|
||||
const note = await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
askedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'MARK_ASKED',
|
||||
entityType: 'NOTE',
|
||||
entityId: noteId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ note })
|
||||
}
|
||||
|
||||
const result = noteSchema.partial().safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
...result.data,
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'NOTE',
|
||||
entityId: noteId,
|
||||
details: result.data,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ note })
|
||||
} catch (error) {
|
||||
console.error('Update note error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/workspaces/[id]/notes/[noteId] (soft delete)
|
||||
export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id: workspaceId, noteId } = 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.note.findFirst({
|
||||
where: { id: noteId, workspaceId, deletedAt: null },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
updatedById: req.session.user.id,
|
||||
version: { increment: 1 },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'NOTE',
|
||||
entityId: noteId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ message: 'Note deleted' })
|
||||
} catch (error) {
|
||||
console.error('Delete note error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
106
src/app/api/workspaces/[id]/notes/route.ts
Normal file
106
src/app/api/workspaces/[id]/notes/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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 { noteSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces/[id]/notes - List notes
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 type = searchParams.get('type') as 'QUESTION' | 'GENERAL' | null
|
||||
const includeDeleted = searchParams.get('includeDeleted') === 'true'
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
workspaceId,
|
||||
...(type ? { type } : {}),
|
||||
...(includeDeleted ? {} : { deletedAt: null }),
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ notes })
|
||||
} catch (error) {
|
||||
console.error('List notes error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces/[id]/notes - Create note
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
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 = noteSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: result.data.type,
|
||||
content: result.data.content,
|
||||
createdById: req.session.user.id,
|
||||
updatedById: req.session.user.id,
|
||||
},
|
||||
include: {
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
updatedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: req.session.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'NOTE',
|
||||
entityId: note.id,
|
||||
details: { type: note.type },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ note }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create note error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
145
src/app/api/workspaces/[id]/route.ts
Normal file
145
src/app/api/workspaces/[id]/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { updateWorkspaceSchema } from '@/lib/validation'
|
||||
|
||||
// Helper to check workspace access
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
requiredRoles: string[] = ['OWNER', 'EDITOR', 'VIEWER']
|
||||
) {
|
||||
const member = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
},
|
||||
})
|
||||
|
||||
if (!member || !requiredRoles.includes(member.role)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return member
|
||||
}
|
||||
|
||||
// GET /api/workspaces/[id] - Get workspace details
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const member = await checkWorkspaceAccess(id, req.session.user.id)
|
||||
if (!member) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
clinicPhone: workspace.clinicPhone,
|
||||
emergencyPhone: workspace.emergencyPhone,
|
||||
quietHoursStart: workspace.quietHoursStart,
|
||||
quietHoursEnd: workspace.quietHoursEnd,
|
||||
largeTextMode: workspace.largeTextMode,
|
||||
role: member.role,
|
||||
members: workspace.members.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
user: m.user,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get workspace error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get workspace' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// PATCH /api/workspaces/[id] - Update workspace settings
|
||||
export const PATCH = withAuth(async (req: AuthenticatedRequest, { params }) => {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const member = await checkWorkspaceAccess(id, req.session.user.id, ['OWNER', 'EDITOR'])
|
||||
if (!member) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const result = updateWorkspaceSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const workspace = await prisma.workspace.update({
|
||||
where: { id },
|
||||
data: result.data,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
quietHoursStart: true,
|
||||
quietHoursEnd: true,
|
||||
largeTextMode: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId: id,
|
||||
userId: req.session.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'WORKSPACE',
|
||||
entityId: id,
|
||||
details: result.data,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ workspace })
|
||||
} catch (error) {
|
||||
console.error('Update workspace error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update workspace' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
92
src/app/api/workspaces/route.ts
Normal file
92
src/app/api/workspaces/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { withAuth, type AuthenticatedRequest } from '@/lib/auth'
|
||||
import { createWorkspaceSchema } from '@/lib/validation'
|
||||
|
||||
// GET /api/workspaces - List user's workspaces
|
||||
export const GET = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const memberships = await prisma.workspaceMember.findMany({
|
||||
where: { userId: req.session.user.id },
|
||||
include: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
quietHoursStart: true,
|
||||
quietHoursEnd: true,
|
||||
largeTextMode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
workspaces: memberships.map((m) => ({
|
||||
...m.workspace,
|
||||
role: m.role,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('List workspaces error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list workspaces' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/workspaces - Create a new workspace
|
||||
export const POST = withAuth(async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = createWorkspaceSchema.safeParse(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: result.error.flatten() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name } = result.data
|
||||
|
||||
const workspace = await prisma.workspace.create({
|
||||
data: {
|
||||
name,
|
||||
members: {
|
||||
create: {
|
||||
userId: req.session.user.id,
|
||||
role: 'OWNER',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
clinicPhone: true,
|
||||
emergencyPhone: true,
|
||||
quietHoursStart: true,
|
||||
quietHoursEnd: true,
|
||||
largeTextMode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: {
|
||||
...workspace,
|
||||
role: 'OWNER',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Create workspace error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create workspace' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
211
src/app/globals.css
Normal file
211
src/app/globals.css
Normal file
@@ -0,0 +1,211 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 250 251 252;
|
||||
--foreground: 31 38 49;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-secondary-900 antialiased;
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
/* Large text mode */
|
||||
.large-text body,
|
||||
.large-text {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.large-text h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
.large-text h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.large-text h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.large-text .text-sm {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.large-text .text-xs {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* iOS safe areas */
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
}
|
||||
|
||||
/* Better touch targets */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
@apply touch-manipulation;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Primary taken button */
|
||||
.btn-taken {
|
||||
@apply bg-primary-500 hover:bg-primary-600 text-white font-semibold;
|
||||
@apply py-3 px-6 rounded-button min-h-touch;
|
||||
@apply shadow-button transition-all duration-200;
|
||||
@apply active:scale-95;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card-appointment {
|
||||
@apply bg-surface rounded-card shadow-card p-4;
|
||||
@apply border-l-4 border-primary-500;
|
||||
}
|
||||
|
||||
.card-medication {
|
||||
@apply bg-surface rounded-card shadow-card p-4;
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
/* Overdue styles */
|
||||
.overdue {
|
||||
@apply border-l-4 border-red-500 bg-red-50;
|
||||
}
|
||||
|
||||
/* Timeline styles */
|
||||
.timeline-item {
|
||||
@apply relative pl-6 pb-4;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
@apply absolute left-0 top-2 w-2 h-2 rounded-full bg-primary-400;
|
||||
}
|
||||
|
||||
.timeline-item::after {
|
||||
content: '';
|
||||
@apply absolute left-[3px] top-4 w-0.5 h-full bg-border;
|
||||
}
|
||||
|
||||
.timeline-item:last-child::after {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: animateIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-out {
|
||||
animation: animateOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animateOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-4 {
|
||||
animation: slideInFromBottom 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-out-to-bottom-4 {
|
||||
animation: slideOutToBottom 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToBottom {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-in-95 {
|
||||
animation: zoomIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/app/invite/[token]/page.tsx
Normal file
177
src/app/invite/[token]/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Heart, Users, AlertCircle } from 'lucide-react'
|
||||
import { Button, Card, LoadingState, showToast } from '@/components/ui'
|
||||
|
||||
interface InviteInfo {
|
||||
workspaceName: string
|
||||
role: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export default function InvitePage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [needsAuth, setNeedsAuth] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInvite = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/invite/${token}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Invalid invite link')
|
||||
return
|
||||
}
|
||||
|
||||
setInviteInfo(data.invite)
|
||||
} catch {
|
||||
setError('Failed to load invite')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInvite()
|
||||
}, [token])
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invite/${token}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status === 401) {
|
||||
setNeedsAuth(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Failed to accept invite')
|
||||
return
|
||||
}
|
||||
|
||||
showToast(`Joined ${data.workspace.name}!`, 'success')
|
||||
router.push('/today')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<LoadingState message="Loading invite..." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !inviteInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-secondary-900 mb-2">
|
||||
Invite Not Available
|
||||
</h1>
|
||||
<p className="text-secondary-500 mb-6">{error}</p>
|
||||
<Link href="/login">
|
||||
<Button fullWidth variant="secondary">
|
||||
Go to Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (needsAuth) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-secondary-900 mb-2">
|
||||
Sign In Required
|
||||
</h1>
|
||||
<p className="text-secondary-500 mb-6">
|
||||
Create an account or sign in to join {inviteInfo?.workspaceName}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link href={`/register?redirect=/invite/${token}`}>
|
||||
<Button fullWidth>Create Account</Button>
|
||||
</Link>
|
||||
<Link href={`/login?redirect=/invite/${token}`}>
|
||||
<Button fullWidth variant="secondary">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-secondary-900">
|
||||
You're Invited!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 text-center">
|
||||
<p className="text-secondary-600 mb-4">
|
||||
You've been invited to join
|
||||
</p>
|
||||
<p className="text-xl font-bold text-secondary-900 mb-2">
|
||||
{inviteInfo?.workspaceName}
|
||||
</p>
|
||||
<p className="text-sm text-secondary-500">
|
||||
as {inviteInfo?.role === 'EDITOR' ? 'an Editor' : 'a Viewer'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button mt-4">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleAccept} fullWidth loading={accepting}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-secondary-500 mt-4">
|
||||
This invite expires on{' '}
|
||||
{inviteInfo && new Date(inviteInfo.expiresAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/app/layout.tsx
Normal file
43
src/app/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Toaster } from '@/components/ui'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Step - Health Management',
|
||||
description: 'A calm, reliable app to help manage appointments, medications, and notes for health care.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'Next Step',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: '#3a9563',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
99
src/app/login/page.tsx
Normal file
99
src/app/login/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } 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() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Login failed')
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Welcome back!', 'success')
|
||||
router.push('/today')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Next Step</h1>
|
||||
<p className="text-secondary-500 mt-1">Health management made simple</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-secondary-500">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
src/app/onboarding/page.tsx
Normal file
158
src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Heart, Shield, ArrowRight } from 'lucide-react'
|
||||
import { Button, Input, Card, showToast } from '@/components/ui'
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<'disclaimer' | 'workspace'>('disclaimer')
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
const [clinicPhone, setClinicPhone] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleAcceptDisclaimer = () => {
|
||||
setStep('workspace')
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Create workspace
|
||||
const createRes = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: workspaceName }),
|
||||
})
|
||||
|
||||
const createData = await createRes.json()
|
||||
|
||||
if (!createRes.ok) {
|
||||
throw new Error(createData.error || 'Failed to create workspace')
|
||||
}
|
||||
|
||||
// Update with clinic phone if provided
|
||||
if (clinicPhone.trim()) {
|
||||
await fetch(`/api/workspaces/${createData.workspace.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clinicPhone: clinicPhone.trim() }),
|
||||
})
|
||||
}
|
||||
|
||||
showToast('All set! Welcome to Next Step.', 'success')
|
||||
router.push('/today')
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'disclaimer') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-amber-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Important Notice</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="space-y-4 text-secondary-700">
|
||||
<p>
|
||||
<strong>Next Step is a tracking tool only.</strong> It helps you and your family
|
||||
stay organized with appointments and medications.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong className="text-red-600">This app does not provide medical advice.</strong>{' '}
|
||||
Always consult your healthcare team for medical decisions.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>For emergencies:</strong> Call 000 (Australia) or your local emergency
|
||||
services immediately.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you have questions about your treatment, contact your clinic directly using the
|
||||
button we'll help you set up.
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-secondary-500">
|
||||
By continuing, you acknowledge that Next Step is for tracking purposes only and
|
||||
does not replace professional medical advice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleAcceptDisclaimer} fullWidth>
|
||||
I Understand
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Set Up Your Plan</h1>
|
||||
<p className="text-secondary-500 mt-1">Create a workspace to get started</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleCreateWorkspace} className="space-y-4">
|
||||
<Input
|
||||
label="Workspace Name"
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||
placeholder="e.g., Grace's Plan"
|
||||
helperText="This is how family members will identify this workspace"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Clinic Phone Number"
|
||||
type="tel"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="e.g., 08 9400 1234"
|
||||
helperText="We'll add a 'Call Clinic' button for quick access"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Create Workspace
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-sm text-secondary-500">
|
||||
You can add family members later from Settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/app/page.tsx
Normal file
12
src/app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
redirect('/today')
|
||||
}
|
||||
130
src/app/register/page.tsx
Normal file
130
src/app/register/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } 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() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Registration failed')
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Account created! Let\'s get started.', 'success')
|
||||
router.push('/onboarding')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Heart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">Create Account</h1>
|
||||
<p className="text-secondary-500 mt-1">Join Next Step to get started</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Your Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Grace"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-button">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={loading}>
|
||||
Create Account
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-secondary-500">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary-600 font-medium hover:text-primary-700">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/components/layout/bottom-nav.tsx
Normal file
51
src/components/layout/bottom-nav.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { clsx } from 'clsx'
|
||||
import { Home, Calendar, Pill, FileText, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/today', label: 'Today', icon: Home },
|
||||
{ href: '/appointments', label: 'Appointments', icon: Calendar },
|
||||
{ href: '/meds', label: 'Meds', icon: Pill },
|
||||
{ href: '/notes', label: 'Notes', icon: FileText },
|
||||
{ href: '/settings', label: 'More', icon: MoreHorizontal },
|
||||
]
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-border safe-area-bottom z-40">
|
||||
<div className="flex items-center justify-around max-w-lg mx-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/today' && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center py-2 px-3 min-w-[64px] min-h-touch',
|
||||
'transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-primary-600'
|
||||
: 'text-secondary-500 hover:text-secondary-700'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx('w-6 h-6 mb-0.5', isActive && 'stroke-[2.5]')}
|
||||
/>
|
||||
<span className={clsx('text-xs', isActive && 'font-medium')}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
98
src/components/layout/header.tsx
Normal file
98
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { clsx } from 'clsx'
|
||||
import { ChevronLeft, Plus } from 'lucide-react'
|
||||
|
||||
interface HeaderProps {
|
||||
title: string
|
||||
showBack?: boolean
|
||||
backHref?: string
|
||||
rightAction?: {
|
||||
icon?: React.ReactNode
|
||||
label?: string
|
||||
onClick: () => void
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Header({
|
||||
title,
|
||||
showBack = false,
|
||||
backHref,
|
||||
rightAction,
|
||||
className,
|
||||
}: HeaderProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (backHref) {
|
||||
router.push(backHref)
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
'sticky top-0 bg-surface/95 backdrop-blur-sm border-b border-border z-30',
|
||||
'safe-area-top',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-14 px-4 max-w-lg mx-auto">
|
||||
{/* Left side */}
|
||||
<div className="w-10">
|
||||
{showBack && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-secondary-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-lg font-semibold text-secondary-900 truncate">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="w-10 flex justify-end">
|
||||
{rightAction && (
|
||||
<button
|
||||
onClick={rightAction.onClick}
|
||||
className="p-2 -mr-2 rounded-full hover:bg-muted transition-colors"
|
||||
aria-label={rightAction.label}
|
||||
>
|
||||
{rightAction.icon || <Plus className="w-6 h-6 text-secondary-700" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageContainerProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
export function PageContainer({ children, className, noPadding = false }: PageContainerProps) {
|
||||
return (
|
||||
<main
|
||||
className={clsx(
|
||||
'min-h-screen bg-background pb-20', // pb-20 for bottom nav
|
||||
!noPadding && 'px-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="max-w-lg mx-auto">{children}</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
118
src/components/ui/button.tsx
Normal file
118
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseStyles = clsx(
|
||||
'inline-flex items-center justify-center font-semibold rounded-button transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'active:scale-[0.98]',
|
||||
'min-h-touch' // Touch-friendly minimum height
|
||||
)
|
||||
|
||||
const variantStyles = {
|
||||
primary: clsx(
|
||||
'bg-primary-600 text-white',
|
||||
'hover:bg-primary-700',
|
||||
'focus:ring-primary-500',
|
||||
'shadow-button'
|
||||
),
|
||||
secondary: clsx(
|
||||
'bg-secondary-100 text-secondary-800',
|
||||
'hover:bg-secondary-200',
|
||||
'focus:ring-secondary-500',
|
||||
'border border-secondary-200'
|
||||
),
|
||||
ghost: clsx(
|
||||
'bg-transparent text-secondary-700',
|
||||
'hover:bg-secondary-100',
|
||||
'focus:ring-secondary-500'
|
||||
),
|
||||
danger: clsx(
|
||||
'bg-red-600 text-white',
|
||||
'hover:bg-red-700',
|
||||
'focus:ring-red-500',
|
||||
'shadow-button'
|
||||
),
|
||||
success: clsx(
|
||||
'bg-primary-500 text-white',
|
||||
'hover:bg-primary-600',
|
||||
'focus:ring-primary-400',
|
||||
'shadow-button'
|
||||
),
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-2 text-sm min-h-[36px]',
|
||||
md: 'px-4 py-2.5 text-base min-h-touch',
|
||||
lg: 'px-6 py-3 text-lg min-h-[56px]',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Button.displayName = 'Button'
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: 'default' | 'elevated' | 'outline'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
onClick,
|
||||
}: CardProps) {
|
||||
const baseStyles = 'rounded-card bg-surface'
|
||||
|
||||
const variantStyles = {
|
||||
default: 'shadow-card',
|
||||
elevated: 'shadow-card-hover',
|
||||
outline: 'border border-border',
|
||||
}
|
||||
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4 md:p-5',
|
||||
lg: 'p-5 md:p-6',
|
||||
}
|
||||
|
||||
const clickableStyles = onClick
|
||||
? 'cursor-pointer hover:shadow-card-hover transition-shadow duration-200'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
paddingStyles[padding],
|
||||
clickableStyles,
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between mb-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardTitleProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4'
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className, as: Component = 'h3' }: CardTitleProps) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx('text-lg font-semibold text-secondary-900', className)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardContentProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CardContent({ children, className }: CardContentProps) {
|
||||
return <div className={clsx('text-secondary-700', className)}>{children}</div>
|
||||
}
|
||||
6
src/components/ui/index.ts
Normal file
6
src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Button } from './button'
|
||||
export { Card, CardHeader, CardTitle, CardContent } from './card'
|
||||
export { Input, Textarea, Select } from './input'
|
||||
export { Modal, ConfirmModal } from './modal'
|
||||
export { LoadingState, EmptyState, ErrorState, SyncBanner } from './states'
|
||||
export { Toaster, showToast, showUndoToast } from './toast'
|
||||
163
src/components/ui/input.tsx
Normal file
163
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, className, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-secondary-700 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full rounded-button border px-4 py-3 text-base',
|
||||
'transition-colors duration-200',
|
||||
'placeholder:text-secondary-400',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||
'min-h-touch',
|
||||
error
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
|
||||
: 'border-border focus:border-primary-500 focus:ring-primary-200',
|
||||
'disabled:bg-muted disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-secondary-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ label, error, helperText, className, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-secondary-700 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full rounded-button border px-4 py-3 text-base',
|
||||
'transition-colors duration-200',
|
||||
'placeholder:text-secondary-400',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||
'resize-none',
|
||||
error
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
|
||||
: 'border-border focus:border-primary-500 focus:ring-primary-200',
|
||||
'disabled:bg-muted disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1.5 text-sm text-secondary-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, error, options, className, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-secondary-700 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full rounded-button border px-4 py-3 text-base',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||
'min-h-touch',
|
||||
'bg-white',
|
||||
error
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-200'
|
||||
: 'border-border focus:border-primary-500 focus:ring-primary-200',
|
||||
'disabled:bg-muted disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
159
src/components/ui/modal.tsx
Normal file
159
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from './button'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'full'
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
full: 'max-w-full mx-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-end md:items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={clsx(
|
||||
'relative bg-surface rounded-t-2xl md:rounded-card w-full',
|
||||
'max-h-[90vh] overflow-y-auto',
|
||||
'animate-in slide-in-from-bottom-4 md:fade-in md:zoom-in-95 duration-200',
|
||||
sizeStyles[size]
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="sticky top-0 bg-surface flex items-center justify-between p-4 border-b border-border">
|
||||
{title && (
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-muted transition-colors -mr-2"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-5 h-5 text-secondary-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'primary'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-secondary-600 mb-6">{message}</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={onClose} fullWidth disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === 'danger' ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
fullWidth
|
||||
loading={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
123
src/components/ui/states.tsx
Normal file
123
src/components/ui/states.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
import { Calendar, Pill, FileText, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from './button'
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingState({ message = 'Loading...', className }: LoadingStateProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col items-center justify-center py-12', className)}>
|
||||
<div className="relative w-12 h-12 mb-4">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-muted" />
|
||||
<div className="absolute inset-0 rounded-full border-4 border-primary-500 border-t-transparent animate-spin" />
|
||||
</div>
|
||||
<p className="text-secondary-500">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
type: 'appointments' | 'medications' | 'notes' | 'general'
|
||||
title: string
|
||||
description?: string
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const icons = {
|
||||
appointments: Calendar,
|
||||
medications: Pill,
|
||||
notes: FileText,
|
||||
general: AlertCircle,
|
||||
}
|
||||
|
||||
const Icon = icons[type]
|
||||
|
||||
return (
|
||||
<div className={clsx('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-secondary-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 mb-1">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-secondary-500 max-w-xs mb-4">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button onClick={action.onClick} variant="primary">
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = 'Something went wrong',
|
||||
message = 'We had trouble loading this content.',
|
||||
onRetry,
|
||||
className,
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div className={clsx('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 mb-1">{title}</h3>
|
||||
<p className="text-secondary-500 max-w-xs mb-4">{message}</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="secondary">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SyncBannerProps {
|
||||
hasConflict: boolean
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function SyncBanner({ hasConflict, onDismiss }: SyncBannerProps) {
|
||||
if (!hasConflict) return null
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-sm text-amber-800">
|
||||
Updated on another device
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-amber-600 hover:text-amber-800 font-medium"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/components/ui/toast.tsx
Normal file
79
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import toast, { Toaster as HotToaster } from 'react-hot-toast'
|
||||
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<HotToaster
|
||||
position="bottom-center"
|
||||
containerStyle={{
|
||||
bottom: 80, // Above bottom nav
|
||||
}}
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#1f2631',
|
||||
color: '#fff',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.9375rem',
|
||||
maxWidth: '380px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-5 h-5 text-green-400" />,
|
||||
error: <XCircle className="w-5 h-5 text-red-400" />,
|
||||
info: <AlertCircle className="w-5 h-5 text-blue-400" />,
|
||||
}
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 bg-secondary-900 text-white rounded-card shadow-lg ${
|
||||
t.visible ? 'animate-in slide-in-from-bottom-4' : 'animate-out slide-out-to-bottom-4'
|
||||
}`}
|
||||
>
|
||||
{icons[type]}
|
||||
<span className="flex-1">{message}</span>
|
||||
<button
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: 4000 }
|
||||
)
|
||||
}
|
||||
|
||||
export function showUndoToast(message: string, onUndo: () => void) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 bg-secondary-900 text-white rounded-card shadow-lg ${
|
||||
t.visible ? 'animate-in slide-in-from-bottom-4' : 'animate-out slide-out-to-bottom-4'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
<span className="flex-1">{message}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
onUndo()
|
||||
toast.dismiss(t.id)
|
||||
}}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-button text-sm font-medium transition-colors"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: 5000 }
|
||||
)
|
||||
}
|
||||
17
src/lib/auth/index.ts
Normal file
17
src/lib/auth/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { hashPassword, verifyPassword } from './password'
|
||||
export {
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession,
|
||||
deleteAllUserSessions,
|
||||
getSessionCookieConfig,
|
||||
getSessionCookieClearConfig,
|
||||
type SessionUser,
|
||||
type SessionData,
|
||||
} from './session'
|
||||
export {
|
||||
checkLoginRateLimit,
|
||||
recordLoginAttempt,
|
||||
checkApiRateLimit,
|
||||
} from './rate-limit'
|
||||
export { withAuth, withRateLimit, type AuthenticatedRequest } from './middleware'
|
||||
78
src/lib/auth/middleware.ts
Normal file
78
src/lib/auth/middleware.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession, SessionData } from './session'
|
||||
import { checkApiRateLimit } from './rate-limit'
|
||||
|
||||
export interface AuthenticatedRequest extends NextRequest {
|
||||
session: SessionData
|
||||
}
|
||||
|
||||
type RouteHandler = (
|
||||
req: NextRequest,
|
||||
context: { params: Promise<Record<string, string>> }
|
||||
) => Promise<NextResponse>
|
||||
|
||||
type AuthenticatedRouteHandler = (
|
||||
req: AuthenticatedRequest,
|
||||
context: { params: Promise<Record<string, string>> }
|
||||
) => Promise<NextResponse>
|
||||
|
||||
export function withAuth(handler: AuthenticatedRouteHandler): RouteHandler {
|
||||
return async (req: NextRequest, context) => {
|
||||
// Rate limiting
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'
|
||||
const rateLimit = checkApiRateLimit(ip)
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'Retry-After': '60',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Authentication
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Attach session to request
|
||||
const authenticatedReq = req as AuthenticatedRequest
|
||||
authenticatedReq.session = session
|
||||
|
||||
return handler(authenticatedReq, context)
|
||||
}
|
||||
}
|
||||
|
||||
export function withRateLimit(handler: RouteHandler): RouteHandler {
|
||||
return async (req: NextRequest, context) => {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'
|
||||
const rateLimit = checkApiRateLimit(ip)
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'Retry-After': '60',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const response = await handler(req, context)
|
||||
response.headers.set('X-RateLimit-Remaining', rateLimit.remaining.toString())
|
||||
return response
|
||||
}
|
||||
}
|
||||
21
src/lib/auth/password.ts
Normal file
21
src/lib/auth/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as argon2 from 'argon2'
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
hash: string,
|
||||
password: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
95
src/lib/auth/rate-limit.ts
Normal file
95
src/lib/auth/rate-limit.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
|
||||
const LOGIN_MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS || '5', 10)
|
||||
const LOGIN_LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES || '15', 10)
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remainingAttempts: number
|
||||
lockoutMinutes?: number
|
||||
}
|
||||
|
||||
export async function checkLoginRateLimit(
|
||||
email: string,
|
||||
ipAddress?: string
|
||||
): Promise<RateLimitResult> {
|
||||
const windowStart = new Date()
|
||||
windowStart.setMinutes(windowStart.getMinutes() - LOGIN_LOCKOUT_MINUTES)
|
||||
|
||||
// Count recent failed attempts for this email
|
||||
const recentAttempts = await prisma.loginAttempt.count({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
success: false,
|
||||
createdAt: { gte: windowStart },
|
||||
},
|
||||
})
|
||||
|
||||
if (recentAttempts >= LOGIN_MAX_ATTEMPTS) {
|
||||
return {
|
||||
allowed: false,
|
||||
remainingAttempts: 0,
|
||||
lockoutMinutes: LOGIN_LOCKOUT_MINUTES,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remainingAttempts: LOGIN_MAX_ATTEMPTS - recentAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordLoginAttempt(
|
||||
email: string,
|
||||
success: boolean,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await prisma.loginAttempt.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
success,
|
||||
ipAddress,
|
||||
},
|
||||
})
|
||||
|
||||
// Clean up old attempts (older than 24 hours)
|
||||
const cutoff = new Date()
|
||||
cutoff.setHours(cutoff.getHours() - 24)
|
||||
|
||||
await prisma.loginAttempt.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter for API endpoints
|
||||
const requestCounts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
|
||||
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10)
|
||||
|
||||
export function checkApiRateLimit(identifier: string): { allowed: boolean; remaining: number } {
|
||||
const now = Date.now()
|
||||
const entry = requestCounts.get(identifier)
|
||||
|
||||
if (!entry || entry.resetAt < now) {
|
||||
requestCounts.set(identifier, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 }
|
||||
}
|
||||
|
||||
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return { allowed: false, remaining: 0 }
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count }
|
||||
}
|
||||
|
||||
// Clean up old entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, value] of requestCounts.entries()) {
|
||||
if (value.resetAt < now) {
|
||||
requestCounts.delete(key)
|
||||
}
|
||||
}
|
||||
}, 60000)
|
||||
117
src/lib/auth/session.ts
Normal file
117
src/lib/auth/session.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from '@/lib/db/prisma'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const SESSION_COOKIE_NAME = 'nextstep_session'
|
||||
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
user: SessionUser
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
userId: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<string> {
|
||||
const token = nanoid(64)
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
userAgent,
|
||||
ipAddress,
|
||||
},
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionData | null> {
|
||||
const cookieStore = await cookies()
|
||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
||||
|
||||
if (!sessionToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token: sessionToken },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
// Clean up expired session
|
||||
if (session) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => {})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
sessionId: session.id,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {})
|
||||
}
|
||||
|
||||
export async function deleteAllUserSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } })
|
||||
}
|
||||
|
||||
export function setSessionCookie(token: string): void {
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
|
||||
|
||||
// Note: This is for server-side use. The actual cookie setting
|
||||
// happens in the API route response
|
||||
}
|
||||
|
||||
export function getSessionCookieConfig(token: string) {
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
|
||||
|
||||
return {
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionCookieClearConfig() {
|
||||
return {
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: '',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
expires: new Date(0),
|
||||
path: '/',
|
||||
}
|
||||
}
|
||||
12
src/lib/db/prisma.ts
Normal file
12
src/lib/db/prisma.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma
|
||||
}
|
||||
24
src/lib/db/workspace-access.ts
Normal file
24
src/lib/db/workspace-access.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { prisma } from './prisma'
|
||||
import type { WorkspaceRole } from '@prisma/client'
|
||||
|
||||
export async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
requiredRoles: WorkspaceRole[] = ['OWNER', 'EDITOR', 'VIEWER']
|
||||
): Promise<{ role: WorkspaceRole } | null> {
|
||||
const member = await prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
},
|
||||
})
|
||||
|
||||
if (!member || !requiredRoles.includes(member.role)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { role: member.role }
|
||||
}
|
||||
|
||||
export function canEdit(role: WorkspaceRole): boolean {
|
||||
return role === 'OWNER' || role === 'EDITOR'
|
||||
}
|
||||
337
src/lib/schedule/calculator.test.ts
Normal file
337
src/lib/schedule/calculator.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
calculateMedicationDueStatus,
|
||||
calculateAllMedicationsDue,
|
||||
getMedicationsDueSoon,
|
||||
formatTimeUntil,
|
||||
} from './calculator'
|
||||
import type { Medication, DoseLog, ScheduleData } from './types'
|
||||
|
||||
// Helper to create a medication
|
||||
function createMed(
|
||||
id: string,
|
||||
name: string,
|
||||
scheduleData: ScheduleData,
|
||||
overrides: Partial<Medication> = {}
|
||||
): Medication {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
instructions: null,
|
||||
scheduleType: scheduleData.type,
|
||||
scheduleData,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
active: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a dose log
|
||||
function createDose(
|
||||
medicationId: string,
|
||||
takenAt: Date,
|
||||
undoneAt: Date | null = null
|
||||
): DoseLog {
|
||||
return {
|
||||
id: `dose-${Date.now()}-${Math.random()}`,
|
||||
medicationId,
|
||||
takenAt,
|
||||
undoneAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Scheduling Calculator', () => {
|
||||
describe('FIXED_TIMES schedule', () => {
|
||||
const med = createMed('med1', 'Morning Med', {
|
||||
type: 'FIXED_TIMES',
|
||||
times: ['08:00', '20:00'],
|
||||
})
|
||||
|
||||
it('returns first scheduled time when no doses taken', () => {
|
||||
// At 7am, should show 8am as next due
|
||||
const now = new Date('2024-01-15T07:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getHours()).toBe(8)
|
||||
expect(status.nextDueAt!.getMinutes()).toBe(0)
|
||||
expect(status.isOverdue).toBe(false)
|
||||
})
|
||||
|
||||
it('returns second time after first dose is taken', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
const doses = [createDose('med1', new Date('2024-01-15T08:05:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getHours()).toBe(20)
|
||||
expect(status.nextDueAt!.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
it('returns first time tomorrow after all doses taken', () => {
|
||||
const now = new Date('2024-01-15T21:00:00+08:00')
|
||||
const doses = [
|
||||
createDose('med1', new Date('2024-01-15T08:05:00+08:00')),
|
||||
createDose('med1', new Date('2024-01-15T20:10:00+08:00')),
|
||||
]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getDate()).toBe(16)
|
||||
expect(status.nextDueAt!.getHours()).toBe(8)
|
||||
})
|
||||
|
||||
it('marks as overdue after grace period', () => {
|
||||
// At 9:30am (90 mins after 8am dose), should be overdue
|
||||
const now = new Date('2024-01-15T09:30:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.isOverdue).toBe(true)
|
||||
expect(status.overdueMinutes).toBeGreaterThan(60)
|
||||
})
|
||||
|
||||
it('ignores undone doses', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
const doses = [
|
||||
createDose('med1', new Date('2024-01-15T08:05:00+08:00'), new Date('2024-01-15T08:10:00+08:00')),
|
||||
]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
// Should still show 8am as due since the dose was undone
|
||||
expect(status.isOverdue).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('INTERVAL schedule', () => {
|
||||
const med = createMed('med2', 'Every 8 Hours', {
|
||||
type: 'INTERVAL',
|
||||
hours: 8,
|
||||
startTime: '08:00',
|
||||
})
|
||||
|
||||
it('returns start time when no doses taken and before start time', () => {
|
||||
const now = new Date('2024-01-15T06:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getHours()).toBe(8)
|
||||
})
|
||||
|
||||
it('calculates next interval from last dose', () => {
|
||||
const now = new Date('2024-01-15T12:00:00+08:00')
|
||||
const doses = [createDose('med2', new Date('2024-01-15T08:00:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
// 8 hours after 8am = 4pm
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getHours()).toBe(16)
|
||||
})
|
||||
|
||||
it('handles doses taken at non-scheduled times', () => {
|
||||
const now = new Date('2024-01-15T15:00:00+08:00')
|
||||
const doses = [createDose('med2', new Date('2024-01-15T10:00:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
// 8 hours after 10am = 6pm
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getHours()).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WEEKDAYS schedule', () => {
|
||||
const med = createMed('med3', 'Mon/Wed/Fri', {
|
||||
type: 'WEEKDAYS',
|
||||
days: [1, 3, 5], // Monday, Wednesday, Friday
|
||||
time: '09:00',
|
||||
})
|
||||
|
||||
it('returns today time if today is a scheduled day', () => {
|
||||
// Monday
|
||||
const now = new Date('2024-01-15T07:00:00+08:00') // This is a Monday
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getDate()).toBe(15)
|
||||
expect(status.nextDueAt!.getHours()).toBe(9)
|
||||
})
|
||||
|
||||
it('returns next scheduled day if today is not scheduled', () => {
|
||||
// Tuesday - should return Wednesday
|
||||
const now = new Date('2024-01-16T10:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getDate()).toBe(17) // Wednesday
|
||||
})
|
||||
|
||||
it('skips to next scheduled day after taking dose', () => {
|
||||
// Monday at noon, dose already taken
|
||||
const now = new Date('2024-01-15T12:00:00+08:00')
|
||||
const doses = [createDose('med3', new Date('2024-01-15T09:15:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
expect(status.nextDueAt).toBeDefined()
|
||||
expect(status.nextDueAt!.getDate()).toBe(17) // Wednesday
|
||||
})
|
||||
})
|
||||
|
||||
describe('PRN schedule', () => {
|
||||
const med = createMed('med4', 'Pain Relief', {
|
||||
type: 'PRN',
|
||||
minHoursBetween: 4,
|
||||
})
|
||||
|
||||
it('is available when no doses taken', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.isPRN).toBe(true)
|
||||
expect(status.prnAvailable).toBe(true)
|
||||
expect(status.prnAvailableAt).toBeNull()
|
||||
})
|
||||
|
||||
it('is in cooldown after recent dose', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
const doses = [createDose('med4', new Date('2024-01-15T08:00:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
expect(status.prnAvailable).toBe(false)
|
||||
expect(status.prnAvailableAt).toBeDefined()
|
||||
// Available at 12pm (4 hours after 8am)
|
||||
expect(status.prnAvailableAt!.getHours()).toBe(12)
|
||||
})
|
||||
|
||||
it('becomes available after cooldown period', () => {
|
||||
const now = new Date('2024-01-15T14:00:00+08:00')
|
||||
const doses = [createDose('med4', new Date('2024-01-15T08:00:00+08:00'))]
|
||||
|
||||
const status = calculateMedicationDueStatus(med, now, doses)
|
||||
|
||||
expect(status.prnAvailable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Date range filtering', () => {
|
||||
it('excludes medication before start date', () => {
|
||||
const med = createMed('med5', 'Future Med', {
|
||||
type: 'FIXED_TIMES',
|
||||
times: ['08:00'],
|
||||
}, {
|
||||
startDate: new Date('2024-01-20'),
|
||||
})
|
||||
|
||||
const now = new Date('2024-01-15T08:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes medication after end date', () => {
|
||||
const med = createMed('med6', 'Ended Med', {
|
||||
type: 'FIXED_TIMES',
|
||||
times: ['08:00'],
|
||||
}, {
|
||||
endDate: new Date('2024-01-10'),
|
||||
})
|
||||
|
||||
const now = new Date('2024-01-15T08:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes inactive medication', () => {
|
||||
const med = createMed('med7', 'Inactive Med', {
|
||||
type: 'FIXED_TIMES',
|
||||
times: ['08:00'],
|
||||
}, {
|
||||
active: false,
|
||||
})
|
||||
|
||||
const now = new Date('2024-01-15T07:00:00+08:00')
|
||||
const status = calculateMedicationDueStatus(med, now, [])
|
||||
|
||||
expect(status.nextDueAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAllMedicationsDue', () => {
|
||||
it('sorts medications by priority', () => {
|
||||
const meds = [
|
||||
createMed('med1', 'Due Later', { type: 'FIXED_TIMES', times: ['16:00'] }),
|
||||
createMed('med2', 'Overdue', { type: 'FIXED_TIMES', times: ['06:00'] }),
|
||||
createMed('med3', 'Due Soon', { type: 'FIXED_TIMES', times: ['10:00'] }),
|
||||
]
|
||||
|
||||
const now = new Date('2024-01-15T09:30:00+08:00')
|
||||
const statuses = calculateAllMedicationsDue(meds, now, [])
|
||||
|
||||
// Overdue should be first
|
||||
expect(statuses[0].medication.name).toBe('Overdue')
|
||||
// Then due soon
|
||||
expect(statuses[1].medication.name).toBe('Due Soon')
|
||||
// Then due later
|
||||
expect(statuses[2].medication.name).toBe('Due Later')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMedicationsDueSoon', () => {
|
||||
it('returns only medications due within timeframe', () => {
|
||||
const meds = [
|
||||
createMed('med1', 'Due in 30min', { type: 'FIXED_TIMES', times: ['10:00'] }),
|
||||
createMed('med2', 'Due in 3 hours', { type: 'FIXED_TIMES', times: ['12:30'] }),
|
||||
]
|
||||
|
||||
const now = new Date('2024-01-15T09:30:00+08:00')
|
||||
const dueSoon = getMedicationsDueSoon(meds, now, [], 60)
|
||||
|
||||
expect(dueSoon).toHaveLength(1)
|
||||
expect(dueSoon[0].medication.name).toBe('Due in 30min')
|
||||
})
|
||||
|
||||
it('includes PRN medications that are available', () => {
|
||||
const meds = [
|
||||
createMed('med1', 'PRN Available', { type: 'PRN', minHoursBetween: 4 }),
|
||||
createMed('med2', 'PRN In Cooldown', { type: 'PRN', minHoursBetween: 4 }),
|
||||
]
|
||||
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
const doses = [createDose('med2', new Date('2024-01-15T08:00:00+08:00'))]
|
||||
|
||||
const dueSoon = getMedicationsDueSoon(meds, now, doses, 60)
|
||||
|
||||
expect(dueSoon).toHaveLength(1)
|
||||
expect(dueSoon[0].medication.name).toBe('PRN Available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTimeUntil', () => {
|
||||
it('formats future times correctly', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
|
||||
expect(formatTimeUntil(new Date('2024-01-15T10:30:00+08:00'), now)).toBe('in 30m')
|
||||
expect(formatTimeUntil(new Date('2024-01-15T12:00:00+08:00'), now)).toBe('in 2h')
|
||||
expect(formatTimeUntil(new Date('2024-01-15T12:30:00+08:00'), now)).toBe('in 2h 30m')
|
||||
})
|
||||
|
||||
it('formats overdue times correctly', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
|
||||
expect(formatTimeUntil(new Date('2024-01-15T09:30:00+08:00'), now)).toBe('30m overdue')
|
||||
expect(formatTimeUntil(new Date('2024-01-15T08:00:00+08:00'), now)).toBe('2h overdue')
|
||||
})
|
||||
|
||||
it('shows "Now" for very recent times', () => {
|
||||
const now = new Date('2024-01-15T10:00:00+08:00')
|
||||
expect(formatTimeUntil(new Date('2024-01-15T10:00:30+08:00'), now)).toBe('Now')
|
||||
})
|
||||
})
|
||||
})
|
||||
373
src/lib/schedule/calculator.ts
Normal file
373
src/lib/schedule/calculator.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { toZonedTime, fromZonedTime } from 'date-fns-tz'
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
startOfDay,
|
||||
setHours,
|
||||
setMinutes,
|
||||
isBefore,
|
||||
isAfter,
|
||||
differenceInMinutes,
|
||||
getDay,
|
||||
} from 'date-fns'
|
||||
import type {
|
||||
Medication,
|
||||
DoseLog,
|
||||
MedicationDueStatus,
|
||||
ScheduleData,
|
||||
FixedTimesSchedule,
|
||||
IntervalSchedule,
|
||||
WeekdaysSchedule,
|
||||
PRNSchedule,
|
||||
} from './types'
|
||||
|
||||
const TIMEZONE = process.env.TZ || 'Australia/Perth'
|
||||
const OVERDUE_GRACE_MINUTES = 60
|
||||
|
||||
/**
|
||||
* Parse a time string (HH:mm) into hours and minutes
|
||||
*/
|
||||
function parseTime(timeStr: string): { hours: number; minutes: number } {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number)
|
||||
return { hours, minutes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a date to a specific time of day in the configured timezone
|
||||
*/
|
||||
function setTimeInTimezone(date: Date, timeStr: string): Date {
|
||||
const { hours, minutes } = parseTime(timeStr)
|
||||
const zonedDate = toZonedTime(date, TIMEZONE)
|
||||
const dayStart = startOfDay(zonedDate)
|
||||
const withTime = setMinutes(setHours(dayStart, hours), minutes)
|
||||
return fromZonedTime(withTime, TIMEZONE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last dose for a medication (excluding undone doses)
|
||||
*/
|
||||
function getLastDose(medicationId: string, doseLogs: DoseLog[]): DoseLog | null {
|
||||
const validDoses = doseLogs
|
||||
.filter((d) => d.medicationId === medicationId && !d.undoneAt)
|
||||
.sort((a, b) => b.takenAt.getTime() - a.takenAt.getTime())
|
||||
|
||||
return validDoses[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doses for a medication on a specific day
|
||||
*/
|
||||
function getDosesOnDay(
|
||||
medicationId: string,
|
||||
day: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): DoseLog[] {
|
||||
const dayStart = startOfDay(toZonedTime(day, TIMEZONE))
|
||||
const dayEnd = addDays(dayStart, 1)
|
||||
|
||||
return doseLogs.filter((d) => {
|
||||
if (d.medicationId !== medicationId || d.undoneAt) return false
|
||||
const doseZoned = toZonedTime(d.takenAt, TIMEZONE)
|
||||
return doseZoned >= dayStart && doseZoned < dayEnd
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next due time for FIXED_TIMES schedule
|
||||
*/
|
||||
function calculateFixedTimesDue(
|
||||
med: Medication,
|
||||
schedule: FixedTimesSchedule,
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): Date | null {
|
||||
const nowZoned = toZonedTime(now, TIMEZONE)
|
||||
const todaysDoses = getDosesOnDay(med.id, now, doseLogs)
|
||||
const takenTimes = new Set(
|
||||
todaysDoses.map((d) => {
|
||||
const z = toZonedTime(d.takenAt, TIMEZONE)
|
||||
return `${z.getHours().toString().padStart(2, '0')}:${z.getMinutes().toString().padStart(2, '0')}`
|
||||
})
|
||||
)
|
||||
|
||||
// Sort times chronologically
|
||||
const sortedTimes = [...schedule.times].sort()
|
||||
|
||||
// Find the next due time today that hasn't been taken
|
||||
for (const time of sortedTimes) {
|
||||
const dueTime = setTimeInTimezone(now, time)
|
||||
if (!takenTimes.has(time) && isAfter(dueTime, addMinutes(now, -OVERDUE_GRACE_MINUTES))) {
|
||||
return dueTime
|
||||
}
|
||||
}
|
||||
|
||||
// All today's doses taken or missed, return first time tomorrow
|
||||
const tomorrow = addDays(now, 1)
|
||||
return setTimeInTimezone(tomorrow, sortedTimes[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next due time for INTERVAL schedule
|
||||
*/
|
||||
function calculateIntervalDue(
|
||||
med: Medication,
|
||||
schedule: IntervalSchedule,
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): Date | null {
|
||||
const lastDose = getLastDose(med.id, doseLogs)
|
||||
|
||||
if (lastDose) {
|
||||
// Next dose is interval hours after last dose
|
||||
return addHours(lastDose.takenAt, schedule.hours)
|
||||
}
|
||||
|
||||
// No doses yet - start from the startTime today or tomorrow
|
||||
const startToday = setTimeInTimezone(now, schedule.startTime)
|
||||
|
||||
if (isAfter(startToday, now)) {
|
||||
return startToday
|
||||
}
|
||||
|
||||
// Calculate how many intervals have passed since start time today
|
||||
const minutesSinceStart = differenceInMinutes(now, startToday)
|
||||
const intervalMinutes = schedule.hours * 60
|
||||
const intervalsPassed = Math.floor(minutesSinceStart / intervalMinutes)
|
||||
const nextDue = addMinutes(startToday, (intervalsPassed + 1) * intervalMinutes)
|
||||
|
||||
return nextDue
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next due time for WEEKDAYS schedule
|
||||
*/
|
||||
function calculateWeekdaysDue(
|
||||
med: Medication,
|
||||
schedule: WeekdaysSchedule,
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): Date | null {
|
||||
const nowZoned = toZonedTime(now, TIMEZONE)
|
||||
const todayDayOfWeek = getDay(nowZoned) // 0 = Sunday
|
||||
|
||||
// Check if today is a scheduled day
|
||||
if (schedule.days.includes(todayDayOfWeek)) {
|
||||
const todaysDoses = getDosesOnDay(med.id, now, doseLogs)
|
||||
if (todaysDoses.length === 0) {
|
||||
const dueToday = setTimeInTimezone(now, schedule.time)
|
||||
if (isAfter(dueToday, addMinutes(now, -OVERDUE_GRACE_MINUTES))) {
|
||||
return dueToday
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the next scheduled day
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
const checkDate = addDays(now, i)
|
||||
const checkZoned = toZonedTime(checkDate, TIMEZONE)
|
||||
const checkDayOfWeek = getDay(checkZoned)
|
||||
|
||||
if (schedule.days.includes(checkDayOfWeek)) {
|
||||
return setTimeInTimezone(checkDate, schedule.time)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate PRN availability
|
||||
*/
|
||||
function calculatePRNStatus(
|
||||
med: Medication,
|
||||
schedule: PRNSchedule,
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): { availableAt: Date | null; available: boolean; lastTakenAt: Date | null } {
|
||||
const lastDose = getLastDose(med.id, doseLogs)
|
||||
|
||||
if (!lastDose) {
|
||||
return { availableAt: null, available: true, lastTakenAt: null }
|
||||
}
|
||||
|
||||
const availableAt = addHours(lastDose.takenAt, schedule.minHoursBetween)
|
||||
const available = isBefore(availableAt, now)
|
||||
|
||||
return {
|
||||
availableAt: available ? null : availableAt,
|
||||
available,
|
||||
lastTakenAt: lastDose.takenAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if medication is within its active date range
|
||||
*/
|
||||
function isMedicationActive(med: Medication, now: Date): boolean {
|
||||
if (!med.active) return false
|
||||
|
||||
const nowZoned = toZonedTime(now, TIMEZONE)
|
||||
const today = startOfDay(nowZoned)
|
||||
|
||||
if (med.startDate && isBefore(today, startOfDay(toZonedTime(med.startDate, TIMEZONE)))) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (med.endDate && isAfter(today, startOfDay(toZonedTime(med.endDate, TIMEZONE)))) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the due status for a single medication
|
||||
*/
|
||||
export function calculateMedicationDueStatus(
|
||||
med: Medication,
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): MedicationDueStatus {
|
||||
const schedule = med.scheduleData as ScheduleData
|
||||
const lastDose = getLastDose(med.id, doseLogs)
|
||||
|
||||
// Base status
|
||||
const status: MedicationDueStatus = {
|
||||
medication: med,
|
||||
nextDueAt: null,
|
||||
isOverdue: false,
|
||||
overdueMinutes: 0,
|
||||
isPRN: schedule.type === 'PRN',
|
||||
prnAvailableAt: null,
|
||||
prnAvailable: false,
|
||||
lastTakenAt: lastDose?.takenAt || null,
|
||||
}
|
||||
|
||||
// Check if medication is active
|
||||
if (!isMedicationActive(med, now)) {
|
||||
return status
|
||||
}
|
||||
|
||||
// Calculate based on schedule type
|
||||
switch (schedule.type) {
|
||||
case 'FIXED_TIMES':
|
||||
status.nextDueAt = calculateFixedTimesDue(med, schedule, now, doseLogs)
|
||||
break
|
||||
|
||||
case 'INTERVAL':
|
||||
status.nextDueAt = calculateIntervalDue(med, schedule, now, doseLogs)
|
||||
break
|
||||
|
||||
case 'WEEKDAYS':
|
||||
status.nextDueAt = calculateWeekdaysDue(med, schedule, now, doseLogs)
|
||||
break
|
||||
|
||||
case 'PRN': {
|
||||
const prnStatus = calculatePRNStatus(med, schedule, now, doseLogs)
|
||||
status.prnAvailableAt = prnStatus.availableAt
|
||||
status.prnAvailable = prnStatus.available
|
||||
status.lastTakenAt = prnStatus.lastTakenAt
|
||||
// PRN meds don't have a "due" time
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overdue status
|
||||
if (status.nextDueAt && isBefore(status.nextDueAt, now)) {
|
||||
const overdue = differenceInMinutes(now, status.nextDueAt)
|
||||
if (overdue > OVERDUE_GRACE_MINUTES) {
|
||||
status.isOverdue = true
|
||||
status.overdueMinutes = overdue
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate due status for all medications and sort by priority
|
||||
*/
|
||||
export function calculateAllMedicationsDue(
|
||||
medications: Medication[],
|
||||
now: Date,
|
||||
doseLogs: DoseLog[]
|
||||
): MedicationDueStatus[] {
|
||||
const statuses = medications.map((med) =>
|
||||
calculateMedicationDueStatus(med, now, doseLogs)
|
||||
)
|
||||
|
||||
// Sort by priority:
|
||||
// 1. Overdue (most overdue first)
|
||||
// 2. Due now or soon (by due time)
|
||||
// 3. PRN available
|
||||
// 4. PRN in cooldown
|
||||
// 5. Future doses
|
||||
return statuses.sort((a, b) => {
|
||||
// Overdue first, sorted by how overdue
|
||||
if (a.isOverdue && !b.isOverdue) return -1
|
||||
if (!a.isOverdue && b.isOverdue) return 1
|
||||
if (a.isOverdue && b.isOverdue) {
|
||||
return b.overdueMinutes - a.overdueMinutes
|
||||
}
|
||||
|
||||
// Non-PRN meds with due times
|
||||
if (!a.isPRN && !b.isPRN && a.nextDueAt && b.nextDueAt) {
|
||||
return a.nextDueAt.getTime() - b.nextDueAt.getTime()
|
||||
}
|
||||
|
||||
// PRN available before PRN in cooldown
|
||||
if (a.isPRN && b.isPRN) {
|
||||
if (a.prnAvailable && !b.prnAvailable) return -1
|
||||
if (!a.prnAvailable && b.prnAvailable) return 1
|
||||
}
|
||||
|
||||
// Non-PRN before PRN
|
||||
if (!a.isPRN && b.isPRN) return -1
|
||||
if (a.isPRN && !b.isPRN) return 1
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get medications that are due within the next X minutes
|
||||
*/
|
||||
export function getMedicationsDueSoon(
|
||||
medications: Medication[],
|
||||
now: Date,
|
||||
doseLogs: DoseLog[],
|
||||
withinMinutes: number = 60
|
||||
): MedicationDueStatus[] {
|
||||
const allStatuses = calculateAllMedicationsDue(medications, now, doseLogs)
|
||||
const cutoff = addMinutes(now, withinMinutes)
|
||||
|
||||
return allStatuses.filter((status) => {
|
||||
if (status.isOverdue) return true
|
||||
if (status.isPRN && status.prnAvailable) return true
|
||||
if (status.nextDueAt && isBefore(status.nextDueAt, cutoff)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining until a date
|
||||
*/
|
||||
export function formatTimeUntil(targetDate: Date, now: Date): string {
|
||||
const minutes = differenceInMinutes(targetDate, now)
|
||||
|
||||
if (minutes < 0) {
|
||||
const overdue = Math.abs(minutes)
|
||||
if (overdue < 60) return `${overdue}m overdue`
|
||||
const hours = Math.floor(overdue / 60)
|
||||
const mins = overdue % 60
|
||||
return mins > 0 ? `${hours}h ${mins}m overdue` : `${hours}h overdue`
|
||||
}
|
||||
|
||||
if (minutes < 1) return 'Now'
|
||||
if (minutes < 60) return `in ${minutes}m`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours}h`
|
||||
}
|
||||
18
src/lib/schedule/index.ts
Normal file
18
src/lib/schedule/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type {
|
||||
ScheduleType,
|
||||
ScheduleData,
|
||||
FixedTimesSchedule,
|
||||
IntervalSchedule,
|
||||
WeekdaysSchedule,
|
||||
PRNSchedule,
|
||||
Medication,
|
||||
DoseLog,
|
||||
MedicationDueStatus,
|
||||
} from './types'
|
||||
|
||||
export {
|
||||
calculateMedicationDueStatus,
|
||||
calculateAllMedicationsDue,
|
||||
getMedicationsDueSoon,
|
||||
formatTimeUntil,
|
||||
} from './calculator'
|
||||
58
src/lib/schedule/types.ts
Normal file
58
src/lib/schedule/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type ScheduleType = 'FIXED_TIMES' | 'INTERVAL' | 'WEEKDAYS' | 'PRN'
|
||||
|
||||
export interface FixedTimesSchedule {
|
||||
type: 'FIXED_TIMES'
|
||||
times: string[] // HH:mm format
|
||||
}
|
||||
|
||||
export interface IntervalSchedule {
|
||||
type: 'INTERVAL'
|
||||
hours: number
|
||||
startTime: string // HH:mm format - when to start counting from each day
|
||||
}
|
||||
|
||||
export interface WeekdaysSchedule {
|
||||
type: 'WEEKDAYS'
|
||||
days: number[] // 0-6 (Sunday-Saturday)
|
||||
time: string // HH:mm format
|
||||
}
|
||||
|
||||
export interface PRNSchedule {
|
||||
type: 'PRN'
|
||||
minHoursBetween: number
|
||||
}
|
||||
|
||||
export type ScheduleData =
|
||||
| FixedTimesSchedule
|
||||
| IntervalSchedule
|
||||
| WeekdaysSchedule
|
||||
| PRNSchedule
|
||||
|
||||
export interface Medication {
|
||||
id: string
|
||||
name: string
|
||||
instructions: string | null
|
||||
scheduleType: ScheduleType
|
||||
scheduleData: ScheduleData
|
||||
startDate: Date | null
|
||||
endDate: Date | null
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface DoseLog {
|
||||
id: string
|
||||
medicationId: string
|
||||
takenAt: Date
|
||||
undoneAt: Date | null
|
||||
}
|
||||
|
||||
export interface MedicationDueStatus {
|
||||
medication: Medication
|
||||
nextDueAt: Date | null
|
||||
isOverdue: boolean
|
||||
overdueMinutes: number
|
||||
isPRN: boolean
|
||||
prnAvailableAt: Date | null // For PRN meds, when it becomes available
|
||||
prnAvailable: boolean
|
||||
lastTakenAt: Date | null
|
||||
}
|
||||
124
src/lib/sync/db.ts
Normal file
124
src/lib/sync/db.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
|
||||
export interface LocalAppointment {
|
||||
id: string
|
||||
workspaceId: string
|
||||
title: string
|
||||
datetime: string
|
||||
location: string | null
|
||||
mapUrl: string | null
|
||||
notes: string | null
|
||||
deletedAt: string | null
|
||||
version: number
|
||||
syncedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
updatedBy?: { id: string; name: string }
|
||||
}
|
||||
|
||||
export interface LocalMedication {
|
||||
id: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
instructions: string | null
|
||||
scheduleType: string
|
||||
scheduleData: Record<string, unknown>
|
||||
startDate: string | null
|
||||
endDate: string | null
|
||||
active: boolean
|
||||
deletedAt: string | null
|
||||
version: number
|
||||
syncedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
updatedBy?: { id: string; name: string }
|
||||
}
|
||||
|
||||
export interface LocalNote {
|
||||
id: string
|
||||
workspaceId: string
|
||||
type: 'QUESTION' | 'GENERAL'
|
||||
content: string
|
||||
askedAt: string | null
|
||||
deletedAt: string | null
|
||||
version: number
|
||||
syncedAt: string
|
||||
createdBy?: { id: string; name: string }
|
||||
updatedBy?: { id: string; name: string }
|
||||
}
|
||||
|
||||
export interface LocalDoseLog {
|
||||
id: string
|
||||
medicationId: string
|
||||
workspaceId: string
|
||||
takenAt: string
|
||||
undoneAt: string | null
|
||||
syncedAt: string
|
||||
medication?: { id: string; name: string }
|
||||
loggedBy?: { id: string; name: string }
|
||||
undoneBy?: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface LocalWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
clinicPhone: string | null
|
||||
emergencyPhone: string | null
|
||||
quietHoursStart: string | null
|
||||
quietHoursEnd: string | null
|
||||
largeTextMode: boolean
|
||||
role?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncOp {
|
||||
id: string
|
||||
workspaceId: string
|
||||
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'TAKE_DOSE' | 'UNDO_DOSE' | 'MARK_ASKED'
|
||||
entityType: 'APPOINTMENT' | 'MEDICATION' | 'NOTE' | 'DOSE_LOG'
|
||||
entityId?: string
|
||||
data?: Record<string, unknown>
|
||||
timestamp: number
|
||||
retries: number
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
id: string
|
||||
workspaceId: string
|
||||
cursor: number
|
||||
lastSyncAt: number
|
||||
}
|
||||
|
||||
class NextStepDB extends Dexie {
|
||||
appointments!: Table<LocalAppointment, string>
|
||||
medications!: Table<LocalMedication, string>
|
||||
notes!: Table<LocalNote, string>
|
||||
doseLogs!: Table<LocalDoseLog, string>
|
||||
workspaces!: Table<LocalWorkspace, string>
|
||||
outbox!: Table<SyncOp, string>
|
||||
syncMeta!: Table<SyncMeta, string>
|
||||
|
||||
constructor() {
|
||||
super('NextStepDB')
|
||||
|
||||
this.version(1).stores({
|
||||
appointments: 'id, workspaceId, datetime, deletedAt',
|
||||
medications: 'id, workspaceId, active, deletedAt',
|
||||
notes: 'id, workspaceId, type, deletedAt',
|
||||
doseLogs: 'id, medicationId, workspaceId, takenAt',
|
||||
workspaces: 'id',
|
||||
outbox: 'id, workspaceId, timestamp',
|
||||
syncMeta: 'id, workspaceId',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new NextStepDB()
|
||||
|
||||
// Helper to generate temporary IDs for offline-created items
|
||||
export function generateTempId(): string {
|
||||
return `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
// Helper to check if an ID is temporary
|
||||
export function isTempId(id: string): boolean {
|
||||
return id.startsWith('temp_')
|
||||
}
|
||||
28
src/lib/sync/index.ts
Normal file
28
src/lib/sync/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { db, generateTempId, isTempId } from './db'
|
||||
export type {
|
||||
LocalAppointment,
|
||||
LocalMedication,
|
||||
LocalNote,
|
||||
LocalDoseLog,
|
||||
LocalWorkspace,
|
||||
SyncOp,
|
||||
SyncMeta,
|
||||
} from './db'
|
||||
|
||||
export {
|
||||
getSyncState,
|
||||
subscribeSyncState,
|
||||
sync,
|
||||
pullChanges,
|
||||
pushChanges,
|
||||
startAutoSync,
|
||||
stopAutoSync,
|
||||
addToOutbox,
|
||||
createLocalAppointment,
|
||||
updateLocalAppointment,
|
||||
deleteLocalAppointment,
|
||||
createLocalNote,
|
||||
logDose,
|
||||
undoDose,
|
||||
markQuestionAsked,
|
||||
} from './manager'
|
||||
445
src/lib/sync/manager.ts
Normal file
445
src/lib/sync/manager.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { db, generateTempId, type SyncOp } from './db'
|
||||
import type { LocalAppointment, LocalMedication, LocalNote, LocalDoseLog } from './db'
|
||||
|
||||
const SYNC_INTERVAL = 30000 // 30 seconds
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
interface SyncState {
|
||||
isSyncing: boolean
|
||||
lastSyncAt: number | null
|
||||
error: string | null
|
||||
hasConflict: boolean
|
||||
}
|
||||
|
||||
let syncState: SyncState = {
|
||||
isSyncing: false,
|
||||
lastSyncAt: null,
|
||||
error: null,
|
||||
hasConflict: false,
|
||||
}
|
||||
|
||||
let syncInterval: ReturnType<typeof setInterval> | null = null
|
||||
let listeners: ((state: SyncState) => void)[] = []
|
||||
|
||||
export function getSyncState(): SyncState {
|
||||
return { ...syncState }
|
||||
}
|
||||
|
||||
export function subscribeSyncState(listener: (state: SyncState) => void): () => void {
|
||||
listeners.push(listener)
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach((l) => l({ ...syncState }))
|
||||
}
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Redirect to login
|
||||
window.location.href = '/login'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function pullChanges(workspaceId: string): Promise<boolean> {
|
||||
try {
|
||||
// Get current cursor
|
||||
const meta = await db.syncMeta.get(workspaceId)
|
||||
const cursor = meta?.cursor || 0
|
||||
|
||||
const response = await fetchWithAuth(`/api/sync?workspaceId=${workspaceId}&since=${cursor}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sync fetch failed')
|
||||
}
|
||||
|
||||
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
|
||||
if (data.workspace) {
|
||||
await db.workspaces.put({
|
||||
...data.workspace,
|
||||
updatedAt: data.workspace.updatedAt || new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Update appointments
|
||||
for (const appt of data.appointments || []) {
|
||||
const existing = await db.appointments.get(appt.id)
|
||||
// Last-write-wins: server is authoritative
|
||||
if (!existing || new Date(appt.syncedAt) > new Date(existing.syncedAt)) {
|
||||
await db.appointments.put({
|
||||
...appt,
|
||||
datetime: appt.datetime,
|
||||
syncedAt: appt.syncedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update medications
|
||||
for (const med of data.medications || []) {
|
||||
const existing = await db.medications.get(med.id)
|
||||
if (!existing || new Date(med.syncedAt) > new Date(existing.syncedAt)) {
|
||||
await db.medications.put({
|
||||
...med,
|
||||
syncedAt: med.syncedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update notes
|
||||
for (const note of data.notes || []) {
|
||||
const existing = await db.notes.get(note.id)
|
||||
if (!existing || new Date(note.syncedAt) > new Date(existing.syncedAt)) {
|
||||
await db.notes.put({
|
||||
...note,
|
||||
syncedAt: note.syncedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Append dose logs (append-only, never overwrite)
|
||||
for (const dose of data.doseLogs || []) {
|
||||
const existing = await db.doseLogs.get(dose.id)
|
||||
if (!existing) {
|
||||
await db.doseLogs.add({
|
||||
...dose,
|
||||
takenAt: dose.takenAt,
|
||||
syncedAt: dose.syncedAt,
|
||||
})
|
||||
} else if (dose.undoneAt && !existing.undoneAt) {
|
||||
// Update undo status
|
||||
await db.doseLogs.update(dose.id, {
|
||||
undoneAt: dose.undoneAt,
|
||||
undoneBy: dose.undoneBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync cursor
|
||||
await db.syncMeta.put({
|
||||
id: workspaceId,
|
||||
workspaceId,
|
||||
cursor: data.cursor,
|
||||
lastSyncAt: Date.now(),
|
||||
})
|
||||
})
|
||||
|
||||
syncState.hasConflict = data.hasConflicts
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Pull changes error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushChanges(workspaceId: string): Promise<boolean> {
|
||||
try {
|
||||
const ops = await db.outbox.where('workspaceId').equals(workspaceId).toArray()
|
||||
|
||||
if (ops.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/api/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
ops: ops.map((op) => ({
|
||||
id: op.id,
|
||||
type: op.type,
|
||||
entityType: op.entityType,
|
||||
entityId: op.entityId,
|
||||
data: op.data,
|
||||
timestamp: op.timestamp,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sync push failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Process results and remove successful ops from outbox
|
||||
await db.transaction('rw', [db.outbox, db.appointments, db.notes], async () => {
|
||||
for (const result of data.results) {
|
||||
if (result.success) {
|
||||
// Find the op
|
||||
const op = ops.find((o) => o.id === result.opId)
|
||||
if (op && op.entityId?.startsWith('temp_') && result.entityId) {
|
||||
// Update local entity with real ID
|
||||
if (op.entityType === 'APPOINTMENT') {
|
||||
const local = await db.appointments.get(op.entityId)
|
||||
if (local) {
|
||||
await db.appointments.delete(op.entityId)
|
||||
await db.appointments.put({ ...local, id: result.entityId })
|
||||
}
|
||||
} else if (op.entityType === 'NOTE') {
|
||||
const local = await db.notes.get(op.entityId)
|
||||
if (local) {
|
||||
await db.notes.delete(op.entityId)
|
||||
await db.notes.put({ ...local, id: result.entityId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from outbox
|
||||
await db.outbox.delete(result.opId)
|
||||
} else {
|
||||
// Increment retry count or remove if max retries
|
||||
const op = await db.outbox.get(result.opId)
|
||||
if (op) {
|
||||
if (op.retries >= MAX_RETRIES) {
|
||||
await db.outbox.delete(result.opId)
|
||||
} else {
|
||||
await db.outbox.update(result.opId, { retries: op.retries + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Push changes error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function sync(workspaceId: string): Promise<void> {
|
||||
if (syncState.isSyncing) {
|
||||
return
|
||||
}
|
||||
|
||||
syncState.isSyncing = true
|
||||
syncState.error = null
|
||||
notifyListeners()
|
||||
|
||||
try {
|
||||
// Push first, then pull
|
||||
const pushSuccess = await pushChanges(workspaceId)
|
||||
const pullSuccess = await pullChanges(workspaceId)
|
||||
|
||||
if (pushSuccess && pullSuccess) {
|
||||
syncState.lastSyncAt = Date.now()
|
||||
syncState.error = null
|
||||
} else {
|
||||
syncState.error = 'Sync partially failed'
|
||||
}
|
||||
} catch (error) {
|
||||
syncState.error = error instanceof Error ? error.message : 'Sync failed'
|
||||
} finally {
|
||||
syncState.isSyncing = false
|
||||
notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoSync(workspaceId: string) {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
sync(workspaceId)
|
||||
|
||||
// Set up interval
|
||||
syncInterval = setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
sync(workspaceId)
|
||||
}
|
||||
}, SYNC_INTERVAL)
|
||||
|
||||
// Sync when coming back online
|
||||
window.addEventListener('online', () => sync(workspaceId))
|
||||
}
|
||||
|
||||
export function stopAutoSync() {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for adding to outbox
|
||||
|
||||
export async function addToOutbox(op: Omit<SyncOp, 'id' | 'retries'>): Promise<void> {
|
||||
await db.outbox.add({
|
||||
...op,
|
||||
id: generateTempId(),
|
||||
retries: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Convenience functions for local operations + outbox
|
||||
|
||||
export async function createLocalAppointment(
|
||||
workspaceId: string,
|
||||
data: Omit<LocalAppointment, 'id' | 'workspaceId' | 'version' | 'syncedAt' | 'deletedAt'>
|
||||
): Promise<LocalAppointment> {
|
||||
const id = generateTempId()
|
||||
const appointment: LocalAppointment = {
|
||||
...data,
|
||||
id,
|
||||
workspaceId,
|
||||
version: 1,
|
||||
syncedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
}
|
||||
|
||||
await db.appointments.add(appointment)
|
||||
await addToOutbox({
|
||||
workspaceId,
|
||||
type: 'CREATE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: id,
|
||||
data: {
|
||||
title: data.title,
|
||||
datetime: data.datetime,
|
||||
location: data.location,
|
||||
mapUrl: data.mapUrl,
|
||||
notes: data.notes,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return appointment
|
||||
}
|
||||
|
||||
export async function updateLocalAppointment(
|
||||
appointment: LocalAppointment,
|
||||
updates: Partial<Pick<LocalAppointment, 'title' | 'datetime' | 'location' | 'mapUrl' | 'notes'>>
|
||||
): Promise<void> {
|
||||
await db.appointments.update(appointment.id, {
|
||||
...updates,
|
||||
version: appointment.version + 1,
|
||||
syncedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: appointment.workspaceId,
|
||||
type: 'UPDATE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: appointment.id,
|
||||
data: updates,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteLocalAppointment(appointment: LocalAppointment): Promise<void> {
|
||||
await db.appointments.update(appointment.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
version: appointment.version + 1,
|
||||
syncedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: appointment.workspaceId,
|
||||
type: 'DELETE',
|
||||
entityType: 'APPOINTMENT',
|
||||
entityId: appointment.id,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createLocalNote(
|
||||
workspaceId: string,
|
||||
data: { type: 'QUESTION' | 'GENERAL'; content: string }
|
||||
): Promise<LocalNote> {
|
||||
const id = generateTempId()
|
||||
const note: LocalNote = {
|
||||
id,
|
||||
workspaceId,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
askedAt: null,
|
||||
deletedAt: null,
|
||||
version: 1,
|
||||
syncedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await db.notes.add(note)
|
||||
await addToOutbox({
|
||||
workspaceId,
|
||||
type: 'CREATE',
|
||||
entityType: 'NOTE',
|
||||
entityId: id,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
export async function logDose(
|
||||
workspaceId: string,
|
||||
medicationId: string,
|
||||
medication: { id: string; name: string }
|
||||
): Promise<LocalDoseLog> {
|
||||
const id = generateTempId()
|
||||
const now = new Date().toISOString()
|
||||
const doseLog: LocalDoseLog = {
|
||||
id,
|
||||
medicationId,
|
||||
workspaceId,
|
||||
takenAt: now,
|
||||
undoneAt: null,
|
||||
syncedAt: now,
|
||||
medication,
|
||||
}
|
||||
|
||||
await db.doseLogs.add(doseLog)
|
||||
await addToOutbox({
|
||||
workspaceId,
|
||||
type: 'TAKE_DOSE',
|
||||
entityType: 'DOSE_LOG',
|
||||
entityId: id,
|
||||
data: { medicationId, takenAt: now },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return doseLog
|
||||
}
|
||||
|
||||
export async function undoDose(doseLog: LocalDoseLog): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.doseLogs.update(doseLog.id, { undoneAt: now })
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: doseLog.workspaceId,
|
||||
type: 'UNDO_DOSE',
|
||||
entityType: 'DOSE_LOG',
|
||||
entityId: doseLog.id,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function markQuestionAsked(note: LocalNote): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.notes.update(note.id, { askedAt: now })
|
||||
|
||||
await addToOutbox({
|
||||
workspaceId: note.workspaceId,
|
||||
type: 'MARK_ASKED',
|
||||
entityType: 'NOTE',
|
||||
entityId: note.id,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
1
src/lib/validation/index.ts
Normal file
1
src/lib/validation/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './schemas'
|
||||
134
src/lib/validation/schemas.ts
Normal file
134
src/lib/validation/schemas.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Auth schemas
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
|
||||
})
|
||||
|
||||
// Workspace schemas
|
||||
export const createWorkspaceSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
|
||||
})
|
||||
|
||||
export const updateWorkspaceSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
clinicPhone: z.string().max(50).nullable().optional(),
|
||||
emergencyPhone: z.string().max(50).nullable().optional(),
|
||||
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(),
|
||||
})
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
role: z.enum(['EDITOR', 'VIEWER']),
|
||||
expiresInDays: z.number().min(1).max(30).default(7),
|
||||
})
|
||||
|
||||
// Appointment schemas
|
||||
export const appointmentSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
datetime: z.string().datetime(),
|
||||
location: z.string().max(500).nullable().optional(),
|
||||
mapUrl: z.string().url().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
|
||||
// Medication schedule schemas
|
||||
const fixedTimesScheduleSchema = z.object({
|
||||
type: z.literal('FIXED_TIMES'),
|
||||
times: z.array(z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/)).min(1).max(24),
|
||||
})
|
||||
|
||||
const intervalScheduleSchema = z.object({
|
||||
type: z.literal('INTERVAL'),
|
||||
hours: z.number().min(1).max(72),
|
||||
startTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/),
|
||||
})
|
||||
|
||||
const weekdaysScheduleSchema = z.object({
|
||||
type: z.literal('WEEKDAYS'),
|
||||
days: z.array(z.number().min(0).max(6)).min(1).max(7),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/),
|
||||
})
|
||||
|
||||
const prnScheduleSchema = z.object({
|
||||
type: z.literal('PRN'),
|
||||
minHoursBetween: z.number().min(0.5).max(72),
|
||||
})
|
||||
|
||||
export const scheduleDataSchema = z.discriminatedUnion('type', [
|
||||
fixedTimesScheduleSchema,
|
||||
intervalScheduleSchema,
|
||||
weekdaysScheduleSchema,
|
||||
prnScheduleSchema,
|
||||
])
|
||||
|
||||
export const medicationSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(200),
|
||||
instructions: z.string().max(1000).nullable().optional(),
|
||||
scheduleType: z.enum(['FIXED_TIMES', 'INTERVAL', 'WEEKDAYS', 'PRN']),
|
||||
scheduleData: scheduleDataSchema,
|
||||
startDate: z.string().datetime().nullable().optional(),
|
||||
endDate: z.string().datetime().nullable().optional(),
|
||||
active: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// Dose log schemas
|
||||
export const doseLogSchema = z.object({
|
||||
medicationId: z.string().cuid(),
|
||||
takenAt: z.string().datetime().optional(), // Defaults to now
|
||||
})
|
||||
|
||||
export const undoDoseSchema = z.object({
|
||||
doseLogId: z.string().cuid(),
|
||||
})
|
||||
|
||||
// Note schemas
|
||||
export const noteSchema = z.object({
|
||||
type: z.enum(['QUESTION', 'GENERAL']),
|
||||
content: z.string().min(1, 'Content is required').max(5000),
|
||||
})
|
||||
|
||||
export const markQuestionAskedSchema = z.object({
|
||||
noteId: z.string().cuid(),
|
||||
})
|
||||
|
||||
// Sync schemas
|
||||
export const syncQuerySchema = z.object({
|
||||
workspaceId: z.string().cuid(),
|
||||
since: z.coerce.number().optional(),
|
||||
})
|
||||
|
||||
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']),
|
||||
entityId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export const syncOpsSchema = z.object({
|
||||
workspaceId: z.string().cuid(),
|
||||
ops: z.array(syncOpSchema),
|
||||
})
|
||||
|
||||
// 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 InviteInput = z.infer<typeof inviteSchema>
|
||||
export type AppointmentInput = z.infer<typeof appointmentSchema>
|
||||
export type MedicationInput = z.infer<typeof medicationSchema>
|
||||
export type ScheduleDataInput = z.infer<typeof scheduleDataSchema>
|
||||
export type DoseLogInput = z.infer<typeof doseLogSchema>
|
||||
export type NoteInput = z.infer<typeof noteSchema>
|
||||
export type SyncOp = z.infer<typeof syncOpSchema>
|
||||
88
tailwind.config.ts
Normal file
88
tailwind.config.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Calm, healing palette
|
||||
primary: {
|
||||
50: '#f0f9f4',
|
||||
100: '#dcf1e4',
|
||||
200: '#bbe3cc',
|
||||
300: '#8dcda8',
|
||||
400: '#5bb17f',
|
||||
500: '#3a9563',
|
||||
600: '#2a784e',
|
||||
700: '#235f40',
|
||||
800: '#1f4c35',
|
||||
900: '#1b3f2d',
|
||||
950: '#0d2319',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f5f7fa',
|
||||
100: '#ebeef3',
|
||||
200: '#d2dae5',
|
||||
300: '#aab9ce',
|
||||
400: '#7c93b3',
|
||||
500: '#5c769a',
|
||||
600: '#485e80',
|
||||
700: '#3b4d68',
|
||||
800: '#344257',
|
||||
900: '#2f3a4a',
|
||||
950: '#1f2631',
|
||||
},
|
||||
accent: {
|
||||
50: '#fef6ee',
|
||||
100: '#fdebd7',
|
||||
200: '#fad3ae',
|
||||
300: '#f6b37b',
|
||||
400: '#f18946',
|
||||
500: '#ed6b22',
|
||||
600: '#de5118',
|
||||
700: '#b83c16',
|
||||
800: '#93311a',
|
||||
900: '#772b18',
|
||||
950: '#40130b',
|
||||
},
|
||||
background: '#fafbfc',
|
||||
surface: '#ffffff',
|
||||
muted: '#f1f5f9',
|
||||
border: '#e2e8f0',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
// Large text mode sizes
|
||||
'lg-base': '1.125rem',
|
||||
'lg-lg': '1.25rem',
|
||||
'lg-xl': '1.5rem',
|
||||
'lg-2xl': '1.875rem',
|
||||
'lg-3xl': '2.25rem',
|
||||
},
|
||||
spacing: {
|
||||
// Touch-friendly spacing
|
||||
'touch': '44px',
|
||||
'touch-lg': '56px',
|
||||
},
|
||||
borderRadius: {
|
||||
'card': '16px',
|
||||
'button': '12px',
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04)',
|
||||
'card-hover': '0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05)',
|
||||
'button': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
export default config
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user